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

davidcole1340 / ext-php-rs / 16203548003

10 Jul 2025 06:54PM CUT coverage: 22.331%. Remained the same
16203548003

push

github

web-flow
feat: make Sapi work with ZTS builds

* feat: make Sapi work with ZTS builds

* test: add basic multithreaded SAPI test

* chore: fix lint error

* test: remove problematic SAPI test for now...

Refs: #488

870 of 3896 relevant lines covered (22.33%)

3.65 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
#[allow(clippy::struct_excessive_bools)]
94
#[derive(Parser)]
95
struct Install {
96
    /// Changes the path that the extension is copied to. This will not
97
    /// activate the extension unless `ini_path` is also passed.
98
    #[arg(long)]
99
    #[allow(clippy::struct_field_names)]
100
    install_dir: Option<PathBuf>,
101
    /// Path to the `php.ini` file to update with the new extension.
102
    #[arg(long)]
103
    ini_path: Option<PathBuf>,
104
    /// Installs the extension but doesn't enable the extension in the `php.ini`
105
    /// file.
106
    #[arg(long)]
107
    disable: bool,
108
    /// Whether to install the release version of the extension.
109
    #[arg(long)]
110
    release: bool,
111
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
112
    /// the directory the command is called.
113
    #[arg(long)]
114
    manifest: Option<PathBuf>,
115
    #[arg(short = 'F', long, num_args = 1..)]
116
    features: Option<Vec<String>>,
117
    #[arg(long)]
118
    all_features: bool,
119
    #[arg(long)]
120
    no_default_features: bool,
121
    /// Whether to bypass the install prompt.
122
    #[clap(long)]
123
    yes: bool,
124
}
125

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

145
#[cfg(not(windows))]
146
#[derive(Parser)]
147
struct Stubs {
148
    /// Path to extension to generate stubs for. Defaults for searching the
149
    /// directory the executable is located in.
150
    ext: Option<PathBuf>,
151
    /// Path used to store generated stub file. Defaults to writing to
152
    /// `<ext-name>.stubs.php` in the current directory.
153
    #[arg(short, long)]
154
    out: Option<PathBuf>,
155
    /// Print stubs to stdout rather than write to file. Cannot be used with
156
    /// `out`.
157
    #[arg(long, conflicts_with = "out")]
158
    stdout: bool,
159
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
160
    /// the directory the command is called.
161
    ///
162
    /// This cannot be provided alongside the `ext` option, as that option
163
    /// provides a direct path to the extension shared library.
164
    #[arg(long, conflicts_with = "ext")]
165
    manifest: Option<PathBuf>,
166
    #[arg(short = 'F', long, num_args = 1..)]
167
    features: Option<Vec<String>>,
168
    #[arg(long)]
169
    all_features: bool,
170
    #[arg(long)]
171
    no_default_features: bool,
172
}
173

174
impl Args {
175
    pub fn handle(self) -> CrateResult {
×
176
        match self {
×
177
            Args::Install(install) => install.handle(),
×
178
            Args::Remove(remove) => remove.handle(),
×
179
            #[cfg(not(windows))]
180
            Args::Stubs(stubs) => stubs.handle(),
×
181
        }
182
    }
183
}
184

185
impl Install {
186
    pub fn handle(self) -> CrateResult {
×
187
        let artifact = find_ext(self.manifest.as_ref())?;
×
188
        let ext_path = build_ext(
189
            &artifact,
×
190
            self.release,
×
191
            self.features,
×
192
            self.all_features,
×
193
            self.no_default_features,
×
194
        )?;
195

196
        let (mut ext_dir, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
197
            (install_dir, None)
×
198
        } else {
199
            (get_ext_dir()?, Some(get_php_ini()?))
×
200
        };
201

202
        if let Some(ini_path) = self.ini_path {
×
203
            php_ini = Some(ini_path);
×
204
        }
205

206
        if !self.yes
×
207
            && !Confirm::new()
×
208
                .with_prompt(format!(
×
209
                    "Are you sure you want to install the extension `{}`?",
×
210
                    artifact.name
211
                ))
212
                .interact()?
×
213
        {
214
            bail!("Installation cancelled.");
×
215
        }
216

217
        debug_assert!(ext_path.is_file());
×
218
        let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
×
219

220
        if ext_dir.is_dir() {
×
221
            ext_dir.push(ext_name);
×
222
        }
223

224
        std::fs::copy(&ext_path, &ext_dir).with_context(|| {
×
225
            "Failed to copy extension from target directory to extension directory"
×
226
        })?;
227

228
        if let Some(php_ini) = php_ini {
×
229
            let mut file = OpenOptions::new()
×
230
                .read(true)
231
                .write(true)
232
                .open(php_ini)
×
233
                .with_context(|| "Failed to open `php.ini`")?;
×
234

235
            let mut ext_line = format!("extension={ext_name}");
×
236

237
            let mut new_lines = vec![];
×
238
            for line in BufReader::new(&file).lines() {
×
239
                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
×
240
                if line.contains(&ext_line) {
×
241
                    bail!("Extension already enabled.");
×
242
                }
243

244
                new_lines.push(line);
×
245
            }
246

247
            // Comment out extension if user specifies disable flag
248
            if self.disable {
×
249
                ext_line.insert(0, ';');
×
250
            }
251

252
            new_lines.push(ext_line);
×
253
            file.rewind()?;
×
254
            file.set_len(0)?;
×
255
            file.write(new_lines.join("\n").as_bytes())
×
256
                .with_context(|| "Failed to update `php.ini`")?;
×
257
        }
258

259
        Ok(())
×
260
    }
261
}
262

263
/// Returns the path to the extension directory utilised by the PHP interpreter,
264
/// creating it if one was returned but it does not exist.
265
fn get_ext_dir() -> AResult<PathBuf> {
×
266
    let cmd = Command::new("php")
×
267
        .arg("-r")
268
        .arg("echo ini_get('extension_dir');")
269
        .output()
270
        .context("Failed to call PHP")?;
271
    if !cmd.status.success() {
×
272
        bail!("Failed to call PHP: {:?}", cmd);
×
273
    }
274
    let stdout = String::from_utf8_lossy(&cmd.stdout);
×
275
    let ext_dir = PathBuf::from(stdout.rsplit('\n').next().unwrap());
×
276
    if !ext_dir.is_dir() {
×
277
        if ext_dir.exists() {
×
278
            bail!(
×
279
                "Extension directory returned from PHP is not a valid directory: {:?}",
×
280
                ext_dir
281
            );
282
        }
283

284
        std::fs::create_dir(&ext_dir).with_context(|| {
×
285
            format!(
×
286
                "Failed to create extension directory at {}",
×
287
                ext_dir.display()
×
288
            )
289
        })?;
290
    }
291
    Ok(ext_dir)
×
292
}
293

294
/// Returns the path to the `php.ini` loaded by the PHP interpreter.
295
fn get_php_ini() -> AResult<PathBuf> {
×
296
    let cmd = Command::new("php")
×
297
        .arg("-r")
298
        .arg("echo get_cfg_var('cfg_file_path');")
299
        .output()
300
        .context("Failed to call PHP")?;
301
    if !cmd.status.success() {
×
302
        bail!("Failed to call PHP: {:?}", cmd);
×
303
    }
304
    let stdout = String::from_utf8_lossy(&cmd.stdout);
×
305
    let ini = PathBuf::from(stdout.rsplit('\n').next().unwrap());
×
306
    if !ini.is_file() {
×
307
        bail!(
×
308
            "php.ini does not exist or is not a file at the given path: {:?}",
×
309
            ini
310
        );
311
    }
312
    Ok(ini)
×
313
}
314

315
impl Remove {
316
    pub fn handle(self) -> CrateResult {
×
317
        use std::env::consts;
318

319
        let artifact = find_ext(self.manifest.as_ref())?;
×
320

321
        let (mut ext_path, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
322
            (install_dir, None)
×
323
        } else {
324
            (get_ext_dir()?, Some(get_php_ini()?))
×
325
        };
326

327
        if let Some(ini_path) = self.ini_path {
×
328
            php_ini = Some(ini_path);
×
329
        }
330

331
        let ext_file = format!(
×
332
            "{}{}{}",
333
            consts::DLL_PREFIX,
334
            artifact.name.replace('-', "_"),
×
335
            consts::DLL_SUFFIX
336
        );
337
        ext_path.push(&ext_file);
×
338

339
        if !ext_path.is_file() {
×
340
            bail!("Unable to find extension installed.");
×
341
        }
342

343
        if !self.yes
×
344
            && !Confirm::new()
×
345
                .with_prompt(format!(
×
346
                    "Are you sure you want to remove the extension `{}`?",
×
347
                    artifact.name
348
                ))
349
                .interact()?
×
350
        {
351
            bail!("Installation cancelled.");
×
352
        }
353

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

356
        if let Some(php_ini) = php_ini.filter(|path| path.is_file()) {
×
357
            let mut file = OpenOptions::new()
×
358
                .read(true)
359
                .write(true)
360
                .create(true)
361
                .truncate(false)
362
                .open(php_ini)
×
363
                .with_context(|| "Failed to open `php.ini`")?;
×
364

365
            let mut new_lines = vec![];
×
366
            for line in BufReader::new(&file).lines() {
×
367
                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
×
368
                if !line.contains(&ext_file) {
×
369
                    new_lines.push(line);
×
370
                }
371
            }
372

373
            file.rewind()?;
×
374
            file.set_len(0)?;
×
375
            file.write(new_lines.join("\n").as_bytes())
×
376
                .with_context(|| "Failed to update `php.ini`")?;
×
377
        }
378

379
        Ok(())
×
380
    }
381
}
382

383
#[cfg(not(windows))]
384
impl Stubs {
385
    pub fn handle(self) -> CrateResult {
×
386
        use ext_php_rs::describe::ToStub;
387
        use std::{borrow::Cow, str::FromStr};
388

389
        let ext_path = if let Some(ext_path) = self.ext {
×
390
            ext_path
×
391
        } else {
392
            let target = find_ext(self.manifest.as_ref())?;
×
393
            build_ext(
394
                &target,
×
395
                false,
396
                self.features,
×
397
                self.all_features,
×
398
                self.no_default_features,
×
399
            )?
400
            .into()
401
        };
402

403
        if !ext_path.is_file() {
×
404
            bail!("Invalid extension path given, not a file.");
×
405
        }
406

407
        let ext = self::ext::Ext::load(ext_path)?;
×
408
        let result = ext.describe();
×
409

410
        // Ensure extension and CLI `ext-php-rs` versions are compatible.
411
        let cli_version = semver::VersionReq::from_str(ext_php_rs::VERSION).with_context(|| {
×
412
            "Failed to parse `ext-php-rs` version that `cargo php` was compiled with"
×
413
        })?;
414
        let ext_version = semver::Version::from_str(result.version).with_context(|| {
×
415
            "Failed to parse `ext-php-rs` version that your extension was compiled with"
×
416
        })?;
417

418
        if !cli_version.matches(&ext_version) {
×
419
            bail!("Extension was compiled with an incompatible version of `ext-php-rs` - Extension: {}, CLI: {}", ext_version, cli_version);
×
420
        }
421

422
        let stubs = result
×
423
            .module
×
424
            .to_stub()
425
            .with_context(|| "Failed to generate stubs.")?;
×
426

427
        if self.stdout {
×
428
            print!("{stubs}");
×
429
        } else {
430
            let out_path = if let Some(out_path) = &self.out {
×
431
                Cow::Borrowed(out_path)
×
432
            } else {
433
                let mut cwd = std::env::current_dir()
×
434
                    .with_context(|| "Failed to get current working directory")?;
×
435
                cwd.push(format!("{}.stubs.php", result.module.name));
×
436
                Cow::Owned(cwd)
×
437
            };
438

439
            std::fs::write(out_path.as_ref(), &stubs)
×
440
                .with_context(|| "Failed to write stubs to file")?;
×
441
        }
442

443
        Ok(())
×
444
    }
445
}
446

447
/// Attempts to find an extension in the target directory.
448
fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
×
449
    // TODO(david): Look for cargo manifest option or env
450
    let mut cmd = cargo_metadata::MetadataCommand::new();
×
451
    if let Some(manifest) = manifest {
×
452
        cmd.manifest_path(manifest);
×
453
    }
454

455
    let meta = cmd
×
456
        .features(cargo_metadata::CargoOpt::AllFeatures)
×
457
        .exec()
458
        .with_context(|| "Failed to call `cargo metadata`")?;
×
459

460
    let package = meta
×
461
        .root_package()
462
        .with_context(|| "Failed to retrieve metadata about crate")?;
×
463

464
    let targets: Vec<_> = package
×
465
        .targets
×
466
        .iter()
467
        .filter(|target| {
×
468
            target
×
469
                .crate_types
×
470
                .iter()
×
471
                .any(|ty| ty == &CrateType::DyLib || ty == &CrateType::CDyLib)
×
472
        })
473
        .collect();
474

475
    let target = match targets.len() {
×
476
        0 => bail!("No library targets were found."),
×
477
        1 => targets[0],
×
478
        _ => {
479
            let target_names: Vec<_> = targets.iter().map(|target| &target.name).collect();
×
480
            let chosen = Select::new()
×
481
                .with_prompt("There were multiple library targets detected in the project. Which would you like to use?")
482
                .items(&target_names)
×
483
                .interact()?;
484
            targets[chosen]
×
485
        }
486
    };
487

488
    Ok(target.clone())
×
489
}
490

491
/// Compiles the extension, searching for the given target artifact. If found,
492
/// the path to the extension dynamic library is returned.
493
///
494
/// # Parameters
495
///
496
/// * `target` - The target to compile.
497
/// * `release` - Whether to compile the target in release mode.
498
/// * `features` - Optional list of features.
499
///
500
/// # Returns
501
///
502
/// The path to the target artifact.
503
fn build_ext(
×
504
    target: &Target,
505
    release: bool,
506
    features: Option<Vec<String>>,
507
    all_features: bool,
508
    no_default_features: bool,
509
) -> AResult<Utf8PathBuf> {
510
    let mut cmd = Command::new("cargo");
×
511
    cmd.arg("build")
×
512
        .arg("--message-format=json-render-diagnostics");
513
    if release {
×
514
        cmd.arg("--release");
×
515
    }
516
    if let Some(features) = features {
×
517
        cmd.arg("--features");
×
518
        for feature in features {
×
519
            cmd.arg(feature);
×
520
        }
521
    }
522

523
    if all_features {
×
524
        cmd.arg("--all-features");
×
525
    }
526

527
    if no_default_features {
×
528
        cmd.arg("--no-default-features");
×
529
    }
530

531
    let mut spawn = cmd
×
532
        .stdout(Stdio::piped())
×
533
        .spawn()
534
        .with_context(|| "Failed to spawn `cargo build`")?;
×
535
    let reader = BufReader::new(
536
        spawn
×
537
            .stdout
×
538
            .take()
×
539
            .with_context(|| "Failed to take `cargo build` stdout")?,
×
540
    );
541

542
    let mut artifact = None;
×
543
    for message in cargo_metadata::Message::parse_stream(reader) {
×
544
        let message = message.with_context(|| "Invalid message received from `cargo build`")?;
×
545
        match message {
×
546
            cargo_metadata::Message::CompilerArtifact(a) => {
×
547
                if &a.target == target {
×
548
                    artifact = Some(a);
×
549
                }
550
            }
551
            cargo_metadata::Message::BuildFinished(b) => {
×
552
                if b.success {
×
553
                    break;
×
554
                }
555

556
                bail!("Compilation failed, cancelling installation.")
×
557
            }
558
            _ => {}
×
559
        }
560
    }
561

562
    let artifact = artifact.with_context(|| "Extension artifact was not compiled")?;
×
563
    for file in artifact.filenames {
×
564
        if file.extension() == Some(std::env::consts::DLL_EXTENSION) {
×
565
            return Ok(file);
×
566
        }
567
    }
568

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