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

davidcole1340 / ext-php-rs / 14285961536

05 Apr 2025 09:28PM CUT coverage: 13.062%. Remained the same
14285961536

Pull #417

github

Xenira
docs(coverage): add coverage badge
Pull Request #417: ci(coverage): ignore release pr

520 of 3981 relevant lines covered (13.06%)

1.23 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
pub fn run() -> CrateResult {
×
40
    let mut args: Vec<_> = std::env::args().collect();
×
41

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

49
    Args::parse_from(args).handle()
×
50
}
51

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

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

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

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

156
impl Args {
157
    pub fn handle(self) -> CrateResult {
×
158
        match self {
×
159
            Args::Install(install) => install.handle(),
×
160
            Args::Remove(remove) => remove.handle(),
×
161
            #[cfg(not(windows))]
162
            Args::Stubs(stubs) => stubs.handle(),
×
163
        }
164
    }
165
}
166

167
impl Install {
168
    pub fn handle(self) -> CrateResult {
×
169
        let artifact = find_ext(&self.manifest)?;
×
170
        let ext_path = build_ext(&artifact, self.release)?;
×
171

172
        let (mut ext_dir, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
173
            (install_dir, None)
×
174
        } else {
175
            (get_ext_dir()?, Some(get_php_ini()?))
×
176
        };
177

178
        if let Some(ini_path) = self.ini_path {
×
179
            php_ini = Some(ini_path);
×
180
        }
181

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

193
        debug_assert!(ext_path.is_file());
×
194
        let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
×
195

196
        if ext_dir.is_dir() {
×
197
            ext_dir.push(ext_name);
×
198
        }
199

200
        std::fs::copy(&ext_path, &ext_dir).with_context(|| {
×
201
            "Failed to copy extension from target directory to extension directory"
×
202
        })?;
203

204
        if let Some(php_ini) = php_ini {
×
205
            let mut file = OpenOptions::new()
×
206
                .read(true)
207
                .write(true)
208
                .open(php_ini)
×
209
                .with_context(|| "Failed to open `php.ini`")?;
×
210

211
            let mut ext_line = format!("extension={ext_name}");
×
212

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

223
            // Comment out extension if user specifies disable flag
224
            if self.disable {
×
225
                ext_line.insert(0, ';');
×
226
            }
227

228
            new_lines.push(ext_line);
×
229
            file.rewind()?;
×
230
            file.set_len(0)?;
×
231
            file.write(new_lines.join("\n").as_bytes())
×
232
                .with_context(|| "Failed to update `php.ini`")?;
×
233
        }
234

235
        Ok(())
×
236
    }
237
}
238

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

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

287
impl Remove {
288
    pub fn handle(self) -> CrateResult {
×
289
        use std::env::consts;
290

291
        let artifact = find_ext(&self.manifest)?;
×
292

293
        let (mut ext_path, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
294
            (install_dir, None)
×
295
        } else {
296
            (get_ext_dir()?, Some(get_php_ini()?))
×
297
        };
298

299
        if let Some(ini_path) = self.ini_path {
×
300
            php_ini = Some(ini_path);
×
301
        }
302

303
        let ext_file = format!(
×
304
            "{}{}{}",
305
            consts::DLL_PREFIX,
×
306
            artifact.name.replace('-', "_"),
×
307
            consts::DLL_SUFFIX
×
308
        );
309
        ext_path.push(&ext_file);
×
310

311
        if !ext_path.is_file() {
×
312
            bail!("Unable to find extension installed.");
×
313
        }
314

315
        if !self.yes
×
316
            && !Confirm::new()
×
317
                .with_prompt(format!(
×
318
                    "Are you sure you want to remove the extension `{}`?",
×
319
                    artifact.name
×
320
                ))
321
                .interact()?
×
322
        {
323
            bail!("Installation cancelled.");
×
324
        }
325

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

328
        if let Some(php_ini) = php_ini.filter(|path| path.is_file()) {
×
329
            let mut file = OpenOptions::new()
×
330
                .read(true)
331
                .write(true)
332
                .create(true)
333
                .truncate(false)
334
                .open(php_ini)
×
335
                .with_context(|| "Failed to open `php.ini`")?;
×
336

337
            let mut new_lines = vec![];
×
338
            for line in BufReader::new(&file).lines() {
×
339
                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
×
340
                if !line.contains(&ext_file) {
×
341
                    new_lines.push(line);
×
342
                }
343
            }
344

345
            file.rewind()?;
×
346
            file.set_len(0)?;
×
347
            file.write(new_lines.join("\n").as_bytes())
×
348
                .with_context(|| "Failed to update `php.ini`")?;
×
349
        }
350

351
        Ok(())
×
352
    }
353
}
354

355
#[cfg(not(windows))]
356
impl Stubs {
357
    pub fn handle(self) -> CrateResult {
×
358
        use ext_php_rs::describe::ToStub;
359
        use std::{borrow::Cow, str::FromStr};
360

361
        let ext_path = if let Some(ext_path) = self.ext {
×
362
            ext_path
×
363
        } else {
364
            let target = find_ext(&self.manifest)?;
×
365
            build_ext(&target, false)?.into()
×
366
        };
367

368
        if !ext_path.is_file() {
×
369
            bail!("Invalid extension path given, not a file.");
×
370
        }
371

372
        let ext = self::ext::Ext::load(ext_path)?;
×
373
        let result = ext.describe();
×
374

375
        // Ensure extension and CLI `ext-php-rs` versions are compatible.
376
        let cli_version = semver::VersionReq::from_str(ext_php_rs::VERSION).with_context(|| {
×
377
            "Failed to parse `ext-php-rs` version that `cargo php` was compiled with"
×
378
        })?;
379
        let ext_version = semver::Version::from_str(result.version).with_context(|| {
×
380
            "Failed to parse `ext-php-rs` version that your extension was compiled with"
×
381
        })?;
382

383
        if !cli_version.matches(&ext_version) {
×
384
            bail!("Extension was compiled with an incompatible version of `ext-php-rs` - Extension: {}, CLI: {}", ext_version, cli_version);
×
385
        }
386

387
        let stubs = result
×
388
            .module
×
389
            .to_stub()
390
            .with_context(|| "Failed to generate stubs.")?;
×
391

392
        if self.stdout {
×
393
            print!("{stubs}");
×
394
        } else {
395
            let out_path = if let Some(out_path) = &self.out {
×
396
                Cow::Borrowed(out_path)
×
397
            } else {
398
                let mut cwd = std::env::current_dir()
×
399
                    .with_context(|| "Failed to get current working directory")?;
×
400
                cwd.push(format!("{}.stubs.php", result.module.name));
×
401
                Cow::Owned(cwd)
×
402
            };
403

404
            std::fs::write(out_path.as_ref(), &stubs)
×
405
                .with_context(|| "Failed to write stubs to file")?;
×
406
        }
407

408
        Ok(())
×
409
    }
410
}
411

412
/// Attempts to find an extension in the target directory.
413
fn find_ext(manifest: &Option<PathBuf>) -> AResult<cargo_metadata::Target> {
×
414
    // TODO(david): Look for cargo manifest option or env
415
    let mut cmd = cargo_metadata::MetadataCommand::new();
×
416
    if let Some(manifest) = manifest {
×
417
        cmd.manifest_path(manifest);
×
418
    }
419

420
    let meta = cmd
×
421
        .features(cargo_metadata::CargoOpt::AllFeatures)
×
422
        .exec()
423
        .with_context(|| "Failed to call `cargo metadata`")?;
×
424

425
    let package = meta
×
426
        .root_package()
427
        .with_context(|| "Failed to retrieve metadata about crate")?;
×
428

429
    let targets: Vec<_> = package
×
430
        .targets
×
431
        .iter()
432
        .filter(|target| {
×
433
            target
×
434
                .crate_types
×
435
                .iter()
×
436
                .any(|ty| ty == &CrateType::DyLib || ty == &CrateType::CDyLib)
×
437
        })
438
        .collect();
439

440
    let target = match targets.len() {
×
441
        0 => bail!("No library targets were found."),
×
442
        1 => targets[0],
×
443
        _ => {
444
            let target_names: Vec<_> = targets.iter().map(|target| &target.name).collect();
×
445
            let chosen = Select::new()
×
446
                .with_prompt("There were multiple library targets detected in the project. Which would you like to use?")
447
                .items(&target_names)
×
448
                .interact()?;
449
            targets[chosen]
×
450
        }
451
    };
452

453
    Ok(target.clone())
×
454
}
455

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

475
    let mut spawn = cmd
×
476
        .stdout(Stdio::piped())
×
477
        .spawn()
478
        .with_context(|| "Failed to spawn `cargo build`")?;
×
479
    let reader = BufReader::new(
480
        spawn
×
481
            .stdout
×
482
            .take()
×
483
            .with_context(|| "Failed to take `cargo build` stdout")?,
×
484
    );
485

486
    let mut artifact = None;
×
487
    for message in cargo_metadata::Message::parse_stream(reader) {
×
488
        let message = message.with_context(|| "Invalid message received from `cargo build`")?;
×
489
        match message {
×
490
            cargo_metadata::Message::CompilerArtifact(a) => {
×
491
                if &a.target == target {
×
492
                    artifact = Some(a);
×
493
                }
494
            }
495
            cargo_metadata::Message::BuildFinished(b) => {
×
496
                if !b.success {
×
497
                    bail!("Compilation failed, cancelling installation.")
×
498
                } else {
499
                    break;
×
500
                }
501
            }
502
            _ => continue,
×
503
        }
504
    }
505

506
    let artifact = artifact.with_context(|| "Extension artifact was not compiled")?;
×
507
    for file in artifact.filenames {
×
508
        if file.extension() == Some(std::env::consts::DLL_EXTENSION) {
×
509
            return Ok(file);
×
510
        }
511
    }
512

513
    bail!("Failed to retrieve extension path from artifact")
×
514
}
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