• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

davidcole1340 / ext-php-rs / 14673396328

25 Apr 2025 08:59PM CUT coverage: 14.14%. Remained the same
14673396328

push

github

web-flow
test(integration): reorganise integration tests

Refs: #414

553 of 3911 relevant lines covered (14.14%)

1.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/cli/src/lib.rs
1
#![doc = include_str!("../README.md")]
2

3
#[cfg(not(windows))]
4
mod ext;
5

6
use anyhow::{bail, Context, Result as AResult};
7
use cargo_metadata::{camino::Utf8PathBuf, CrateType, Target};
8
use clap::Parser;
9
use dialoguer::{Confirm, Select};
10

11
use std::{
12
    fs::OpenOptions,
13
    io::{BufRead, BufReader, Seek, Write},
14
    path::PathBuf,
15
    process::{Command, Stdio},
16
};
17

18
/// Generates mock symbols required to generate stub files from a downstream
19
/// crates CLI application.
20
#[macro_export]
21
macro_rules! stub_symbols {
22
    ($($s: ident),*) => {
23
        $(
24
            $crate::stub_symbols!(@INTERNAL; $s);
25
        )*
26
    };
27
    (@INTERNAL; $s: ident) => {
28
        #[allow(non_upper_case_globals)]
29
        #[allow(missing_docs)]
30
        #[no_mangle]
31
        pub static mut $s: *mut () = ::std::ptr::null_mut();
32
    };
33
}
34

35
/// Result type returned from the [`run`] function.
36
pub type CrateResult = AResult<()>;
37

38
/// Runs the CLI application. Returns nothing in a result on success.
39
///
40
/// # Errors
41
///
42
/// Returns an error if the application fails to run.
43
pub fn run() -> CrateResult {
×
44
    let mut args: Vec<_> = std::env::args().collect();
×
45

46
    // When called as a cargo subcommand, the second argument given will be the
47
    // subcommand, in this case `php`. We don't want this so we remove from args and
48
    // pass it to clap.
49
    if args.get(1).is_some_and(|nth| nth == "php") {
×
50
        args.remove(1);
×
51
    }
52

53
    Args::parse_from(args).handle()
×
54
}
55

56
#[derive(Parser)]
57
#[clap(
58
    about = "Installs extensions and generates stub files for PHP extensions generated with `ext-php-rs`.",
59
    author = "David Cole <david.cole1340@gmail.com>",
60
    version = env!("CARGO_PKG_VERSION")
61
)]
62
enum Args {
63
    /// Installs the extension in the current PHP installation.
64
    ///
65
    /// This copies the extension to the PHP installation and adds the
66
    /// extension to a PHP configuration file.
67
    ///
68
    /// Note that this uses the `php-config` executable installed alongside PHP
69
    /// to locate your `php.ini` file and extension directory. If you want to
70
    /// use a different `php-config`, the application will read the `PHP_CONFIG`
71
    /// variable (if it is set), and will use this as the path to the executable
72
    /// instead.
73
    Install(Install),
74
    /// Removes the extension in the current PHP installation.
75
    ///
76
    /// This deletes the extension from the PHP installation and also removes it
77
    /// from the main PHP configuration file.
78
    ///
79
    /// Note that this uses the `php-config` executable installed alongside PHP
80
    /// to locate your `php.ini` file and extension directory. If you want to
81
    /// use a different `php-config`, the application will read the `PHP_CONFIG`
82
    /// variable (if it is set), and will use this as the path to the executable
83
    /// instead.
84
    Remove(Remove),
85
    /// Generates stub PHP files for the extension.
86
    ///
87
    /// These stub files can be used in IDEs to provide typehinting for
88
    /// extension classes, functions and constants.
89
    #[cfg(not(windows))]
90
    Stubs(Stubs),
91
}
92

93
#[derive(Parser)]
94
struct Install {
95
    /// Changes the path that the extension is copied to. This will not
96
    /// activate the extension unless `ini_path` is also passed.
97
    #[arg(long)]
98
    #[allow(clippy::struct_field_names)]
99
    install_dir: Option<PathBuf>,
100
    /// Path to the `php.ini` file to update with the new extension.
101
    #[arg(long)]
102
    ini_path: Option<PathBuf>,
103
    /// Installs the extension but doesn't enable the extension in the `php.ini`
104
    /// file.
105
    #[arg(long)]
106
    disable: bool,
107
    /// Whether to install the release version of the extension.
108
    #[arg(long)]
109
    release: bool,
110
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
111
    /// the directory the command is called.
112
    #[arg(long)]
113
    manifest: Option<PathBuf>,
114
    /// Whether to bypass the install prompt.
115
    #[clap(long)]
116
    yes: bool,
117
}
118

119
#[derive(Parser)]
120
struct Remove {
121
    /// Changes the path that the extension will be removed from. This will not
122
    /// remove the extension from a configuration file unless `ini_path` is also
123
    /// passed.
124
    #[arg(long)]
125
    install_dir: Option<PathBuf>,
126
    /// Path to the `php.ini` file to remove the extension from.
127
    #[arg(long)]
128
    ini_path: Option<PathBuf>,
129
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
130
    /// the directory the command is called.
131
    #[arg(long)]
132
    manifest: Option<PathBuf>,
133
    /// Whether to bypass the remove prompt.
134
    #[clap(long)]
135
    yes: bool,
136
}
137

138
#[cfg(not(windows))]
139
#[derive(Parser)]
140
struct Stubs {
141
    /// Path to extension to generate stubs for. Defaults for searching the
142
    /// directory the executable is located in.
143
    ext: Option<PathBuf>,
144
    /// Path used to store generated stub file. Defaults to writing to
145
    /// `<ext-name>.stubs.php` in the current directory.
146
    #[arg(short, long)]
147
    out: Option<PathBuf>,
148
    /// Print stubs to stdout rather than write to file. Cannot be used with
149
    /// `out`.
150
    #[arg(long, conflicts_with = "out")]
151
    stdout: bool,
152
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
153
    /// the directory the command is called.
154
    ///
155
    /// This cannot be provided alongside the `ext` option, as that option
156
    /// provides a direct path to the extension shared library.
157
    #[arg(long, conflicts_with = "ext")]
158
    manifest: Option<PathBuf>,
159
}
160

161
impl Args {
162
    pub fn handle(self) -> CrateResult {
×
163
        match self {
×
164
            Args::Install(install) => install.handle(),
×
165
            Args::Remove(remove) => remove.handle(),
×
166
            #[cfg(not(windows))]
167
            Args::Stubs(stubs) => stubs.handle(),
×
168
        }
169
    }
170
}
171

172
impl Install {
173
    pub fn handle(self) -> CrateResult {
×
174
        let artifact = find_ext(self.manifest.as_ref())?;
×
175
        let ext_path = build_ext(&artifact, self.release)?;
×
176

177
        let (mut ext_dir, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
178
            (install_dir, None)
×
179
        } else {
180
            (get_ext_dir()?, Some(get_php_ini()?))
×
181
        };
182

183
        if let Some(ini_path) = self.ini_path {
×
184
            php_ini = Some(ini_path);
×
185
        }
186

187
        if !self.yes
×
188
            && !Confirm::new()
×
189
                .with_prompt(format!(
×
190
                    "Are you sure you want to install the extension `{}`?",
×
191
                    artifact.name
×
192
                ))
193
                .interact()?
×
194
        {
195
            bail!("Installation cancelled.");
×
196
        }
197

198
        debug_assert!(ext_path.is_file());
×
199
        let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
×
200

201
        if ext_dir.is_dir() {
×
202
            ext_dir.push(ext_name);
×
203
        }
204

205
        std::fs::copy(&ext_path, &ext_dir).with_context(|| {
×
206
            "Failed to copy extension from target directory to extension directory"
×
207
        })?;
208

209
        if let Some(php_ini) = php_ini {
×
210
            let mut file = OpenOptions::new()
×
211
                .read(true)
212
                .write(true)
213
                .open(php_ini)
×
214
                .with_context(|| "Failed to open `php.ini`")?;
×
215

216
            let mut ext_line = format!("extension={ext_name}");
×
217

218
            let mut new_lines = vec![];
×
219
            for line in BufReader::new(&file).lines() {
×
220
                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
×
221
                if line.contains(&ext_line) {
×
222
                    bail!("Extension already enabled.");
×
223
                }
224

225
                new_lines.push(line);
×
226
            }
227

228
            // Comment out extension if user specifies disable flag
229
            if self.disable {
×
230
                ext_line.insert(0, ';');
×
231
            }
232

233
            new_lines.push(ext_line);
×
234
            file.rewind()?;
×
235
            file.set_len(0)?;
×
236
            file.write(new_lines.join("\n").as_bytes())
×
237
                .with_context(|| "Failed to update `php.ini`")?;
×
238
        }
239

240
        Ok(())
×
241
    }
242
}
243

244
/// Returns the path to the extension directory utilised by the PHP interpreter,
245
/// creating it if one was returned but it does not exist.
246
fn get_ext_dir() -> AResult<PathBuf> {
×
247
    let cmd = Command::new("php")
×
248
        .arg("-r")
249
        .arg("echo ini_get('extension_dir');")
250
        .output()
251
        .context("Failed to call PHP")?;
252
    if !cmd.status.success() {
×
253
        bail!("Failed to call PHP: {:?}", cmd);
×
254
    }
255
    let stdout = String::from_utf8_lossy(&cmd.stdout);
×
256
    let ext_dir = PathBuf::from(&*stdout);
×
257
    if !ext_dir.is_dir() {
×
258
        if ext_dir.exists() {
×
259
            bail!(
×
260
                "Extension directory returned from PHP is not a valid directory: {:?}",
×
261
                ext_dir
×
262
            );
263
        }
264

265
        std::fs::create_dir(&ext_dir).with_context(|| {
×
266
            format!(
×
267
                "Failed to create extension directory at {}",
×
268
                ext_dir.display()
×
269
            )
270
        })?;
271
    }
272
    Ok(ext_dir)
×
273
}
274

275
/// Returns the path to the `php.ini` loaded by the PHP interpreter.
276
fn get_php_ini() -> AResult<PathBuf> {
×
277
    let cmd = Command::new("php")
×
278
        .arg("-r")
279
        .arg("echo get_cfg_var('cfg_file_path');")
280
        .output()
281
        .context("Failed to call PHP")?;
282
    if !cmd.status.success() {
×
283
        bail!("Failed to call PHP: {:?}", cmd);
×
284
    }
285
    let stdout = String::from_utf8_lossy(&cmd.stdout);
×
286
    let ini = PathBuf::from(&*stdout);
×
287
    if !ini.is_file() {
×
288
        bail!(
×
289
            "php.ini does not exist or is not a file at the given path: {:?}",
×
290
            ini
×
291
        );
292
    }
293
    Ok(ini)
×
294
}
295

296
impl Remove {
297
    pub fn handle(self) -> CrateResult {
×
298
        use std::env::consts;
299

300
        let artifact = find_ext(self.manifest.as_ref())?;
×
301

302
        let (mut ext_path, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
303
            (install_dir, None)
×
304
        } else {
305
            (get_ext_dir()?, Some(get_php_ini()?))
×
306
        };
307

308
        if let Some(ini_path) = self.ini_path {
×
309
            php_ini = Some(ini_path);
×
310
        }
311

312
        let ext_file = format!(
×
313
            "{}{}{}",
314
            consts::DLL_PREFIX,
×
315
            artifact.name.replace('-', "_"),
×
316
            consts::DLL_SUFFIX
×
317
        );
318
        ext_path.push(&ext_file);
×
319

320
        if !ext_path.is_file() {
×
321
            bail!("Unable to find extension installed.");
×
322
        }
323

324
        if !self.yes
×
325
            && !Confirm::new()
×
326
                .with_prompt(format!(
×
327
                    "Are you sure you want to remove the extension `{}`?",
×
328
                    artifact.name
×
329
                ))
330
                .interact()?
×
331
        {
332
            bail!("Installation cancelled.");
×
333
        }
334

335
        std::fs::remove_file(ext_path).with_context(|| "Failed to remove extension")?;
×
336

337
        if let Some(php_ini) = php_ini.filter(|path| path.is_file()) {
×
338
            let mut file = OpenOptions::new()
×
339
                .read(true)
340
                .write(true)
341
                .create(true)
342
                .truncate(false)
343
                .open(php_ini)
×
344
                .with_context(|| "Failed to open `php.ini`")?;
×
345

346
            let mut new_lines = vec![];
×
347
            for line in BufReader::new(&file).lines() {
×
348
                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
×
349
                if !line.contains(&ext_file) {
×
350
                    new_lines.push(line);
×
351
                }
352
            }
353

354
            file.rewind()?;
×
355
            file.set_len(0)?;
×
356
            file.write(new_lines.join("\n").as_bytes())
×
357
                .with_context(|| "Failed to update `php.ini`")?;
×
358
        }
359

360
        Ok(())
×
361
    }
362
}
363

364
#[cfg(not(windows))]
365
impl Stubs {
366
    pub fn handle(self) -> CrateResult {
×
367
        use ext_php_rs::describe::ToStub;
368
        use std::{borrow::Cow, str::FromStr};
369

370
        let ext_path = if let Some(ext_path) = self.ext {
×
371
            ext_path
×
372
        } else {
373
            let target = find_ext(self.manifest.as_ref())?;
×
374
            build_ext(&target, false)?.into()
×
375
        };
376

377
        if !ext_path.is_file() {
×
378
            bail!("Invalid extension path given, not a file.");
×
379
        }
380

381
        let ext = self::ext::Ext::load(ext_path)?;
×
382
        let result = ext.describe();
×
383

384
        // Ensure extension and CLI `ext-php-rs` versions are compatible.
385
        let cli_version = semver::VersionReq::from_str(ext_php_rs::VERSION).with_context(|| {
×
386
            "Failed to parse `ext-php-rs` version that `cargo php` was compiled with"
×
387
        })?;
388
        let ext_version = semver::Version::from_str(result.version).with_context(|| {
×
389
            "Failed to parse `ext-php-rs` version that your extension was compiled with"
×
390
        })?;
391

392
        if !cli_version.matches(&ext_version) {
×
393
            bail!("Extension was compiled with an incompatible version of `ext-php-rs` - Extension: {}, CLI: {}", ext_version, cli_version);
×
394
        }
395

396
        let stubs = result
×
397
            .module
×
398
            .to_stub()
399
            .with_context(|| "Failed to generate stubs.")?;
×
400

401
        if self.stdout {
×
402
            print!("{stubs}");
×
403
        } else {
404
            let out_path = if let Some(out_path) = &self.out {
×
405
                Cow::Borrowed(out_path)
×
406
            } else {
407
                let mut cwd = std::env::current_dir()
×
408
                    .with_context(|| "Failed to get current working directory")?;
×
409
                cwd.push(format!("{}.stubs.php", result.module.name));
×
410
                Cow::Owned(cwd)
×
411
            };
412

413
            std::fs::write(out_path.as_ref(), &stubs)
×
414
                .with_context(|| "Failed to write stubs to file")?;
×
415
        }
416

417
        Ok(())
×
418
    }
419
}
420

421
/// Attempts to find an extension in the target directory.
422
fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
×
423
    // TODO(david): Look for cargo manifest option or env
424
    let mut cmd = cargo_metadata::MetadataCommand::new();
×
425
    if let Some(manifest) = manifest {
×
426
        cmd.manifest_path(manifest);
×
427
    }
428

429
    let meta = cmd
×
430
        .features(cargo_metadata::CargoOpt::AllFeatures)
×
431
        .exec()
432
        .with_context(|| "Failed to call `cargo metadata`")?;
×
433

434
    let package = meta
×
435
        .root_package()
436
        .with_context(|| "Failed to retrieve metadata about crate")?;
×
437

438
    let targets: Vec<_> = package
×
439
        .targets
×
440
        .iter()
441
        .filter(|target| {
×
442
            target
×
443
                .crate_types
×
444
                .iter()
×
445
                .any(|ty| ty == &CrateType::DyLib || ty == &CrateType::CDyLib)
×
446
        })
447
        .collect();
448

449
    let target = match targets.len() {
×
450
        0 => bail!("No library targets were found."),
×
451
        1 => targets[0],
×
452
        _ => {
453
            let target_names: Vec<_> = targets.iter().map(|target| &target.name).collect();
×
454
            let chosen = Select::new()
×
455
                .with_prompt("There were multiple library targets detected in the project. Which would you like to use?")
456
                .items(&target_names)
×
457
                .interact()?;
458
            targets[chosen]
×
459
        }
460
    };
461

462
    Ok(target.clone())
×
463
}
464

465
/// Compiles the extension, searching for the given target artifact. If found,
466
/// the path to the extension dynamic library is returned.
467
///
468
/// # Parameters
469
///
470
/// * `target` - The target to compile.
471
/// * `release` - Whether to compile the target in release mode.
472
///
473
/// # Returns
474
///
475
/// The path to the target artifact.
476
fn build_ext(target: &Target, release: bool) -> AResult<Utf8PathBuf> {
×
477
    let mut cmd = Command::new("cargo");
×
478
    cmd.arg("build")
×
479
        .arg("--message-format=json-render-diagnostics");
480
    if release {
×
481
        cmd.arg("--release");
×
482
    }
483

484
    let mut spawn = cmd
×
485
        .stdout(Stdio::piped())
×
486
        .spawn()
487
        .with_context(|| "Failed to spawn `cargo build`")?;
×
488
    let reader = BufReader::new(
489
        spawn
×
490
            .stdout
×
491
            .take()
×
492
            .with_context(|| "Failed to take `cargo build` stdout")?,
×
493
    );
494

495
    let mut artifact = None;
×
496
    for message in cargo_metadata::Message::parse_stream(reader) {
×
497
        let message = message.with_context(|| "Invalid message received from `cargo build`")?;
×
498
        match message {
×
499
            cargo_metadata::Message::CompilerArtifact(a) => {
×
500
                if &a.target == target {
×
501
                    artifact = Some(a);
×
502
                }
503
            }
504
            cargo_metadata::Message::BuildFinished(b) => {
×
505
                if b.success {
×
506
                    break;
×
507
                }
508

509
                bail!("Compilation failed, cancelling installation.")
×
510
            }
511
            _ => {}
×
512
        }
513
    }
514

515
    let artifact = artifact.with_context(|| "Extension artifact was not compiled")?;
×
516
    for file in artifact.filenames {
×
517
        if file.extension() == Some(std::env::consts::DLL_EXTENSION) {
×
518
            return Ok(file);
×
519
        }
520
    }
521

522
    bail!("Failed to retrieve extension path from artifact")
×
523
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc