cargo_php/
lib.rs

1#![doc = include_str!("../README.md")]
2
3#[cfg(not(windows))]
4mod ext;
5
6use anyhow::{Context, Result as AResult, bail};
7use cargo_metadata::{CrateType, Target, camino::Utf8PathBuf};
8use clap::Parser;
9use dialoguer::{Confirm, Select};
10
11use 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]
21macro_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        #[unsafe(no_mangle)]
31        pub static mut $s: *mut () = ::std::ptr::null_mut();
32    };
33}
34
35/// Result type returned from the [`run`] function.
36pub 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.
43pub 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)]
62enum 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)]
95struct 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    /// Skip the smoke test that verifies the extension loads correctly.
125    #[clap(long)]
126    no_smoke_test: bool,
127}
128
129#[derive(Parser)]
130struct Remove {
131    /// Changes the path that the extension will be removed from. This will not
132    /// remove the extension from a configuration file unless `ini_path` is also
133    /// passed.
134    #[arg(long)]
135    install_dir: Option<PathBuf>,
136    /// Path to the `php.ini` file to remove the extension from.
137    #[arg(long)]
138    ini_path: Option<PathBuf>,
139    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
140    /// the directory the command is called.
141    #[arg(long)]
142    manifest: Option<PathBuf>,
143    /// Whether to bypass the remove prompt.
144    #[clap(long)]
145    yes: bool,
146}
147
148#[cfg(not(windows))]
149#[derive(Parser)]
150struct Stubs {
151    /// Path to extension to generate stubs for. Defaults for searching the
152    /// directory the executable is located in.
153    ext: Option<PathBuf>,
154    /// Path used to store generated stub file. Defaults to writing to
155    /// `<ext-name>.stubs.php` in the current directory.
156    #[arg(short, long)]
157    out: Option<PathBuf>,
158    /// Print stubs to stdout rather than write to file. Cannot be used with
159    /// `out`.
160    #[arg(long, conflicts_with = "out")]
161    stdout: bool,
162    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
163    /// the directory the command is called.
164    ///
165    /// This cannot be provided alongside the `ext` option, as that option
166    /// provides a direct path to the extension shared library.
167    #[arg(long, conflicts_with = "ext")]
168    manifest: Option<PathBuf>,
169    #[arg(short = 'F', long, num_args = 1..)]
170    features: Option<Vec<String>>,
171    #[arg(long)]
172    all_features: bool,
173    #[arg(long)]
174    no_default_features: bool,
175}
176
177impl Args {
178    pub fn handle(self) -> CrateResult {
179        match self {
180            Args::Install(install) => install.handle(),
181            Args::Remove(remove) => remove.handle(),
182            #[cfg(not(windows))]
183            Args::Stubs(stubs) => stubs.handle(),
184        }
185    }
186}
187
188impl Install {
189    pub fn handle(self) -> CrateResult {
190        let artifact = find_ext(self.manifest.as_ref())?;
191        let ext_path = build_ext(
192            &artifact,
193            self.release,
194            self.features,
195            self.all_features,
196            self.no_default_features,
197        )?;
198
199        let (mut ext_dir, mut php_ini) = if let Some(install_dir) = self.install_dir {
200            (install_dir, None)
201        } else {
202            (get_ext_dir()?, Some(get_php_ini()?))
203        };
204
205        if let Some(ini_path) = self.ini_path {
206            php_ini = Some(ini_path);
207        }
208
209        if !self.yes
210            && !Confirm::new()
211                .with_prompt(format!(
212                    "Are you sure you want to install the extension `{}`?",
213                    artifact.name
214                ))
215                .interact()?
216        {
217            bail!("Installation cancelled.");
218        }
219
220        debug_assert!(ext_path.is_file());
221        let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
222
223        if ext_dir.is_dir() {
224            ext_dir.push(ext_name);
225        }
226
227        // Use atomic copy: copy to temp file in same directory, then rename.
228        // This prevents race conditions where a partially-written extension could be loaded.
229        let temp_ext_path = ext_dir.with_extension(format!(
230            "{}.tmp.{}",
231            ext_dir
232                .extension()
233                .map(|e| e.to_string_lossy())
234                .unwrap_or_default(),
235            std::process::id()
236        ));
237
238        std::fs::copy(&ext_path, &temp_ext_path).with_context(
239            || "Failed to copy extension from target directory to extension directory",
240        )?;
241
242        // Rename is atomic on POSIX when source and destination are on the same filesystem
243        if let Err(e) = std::fs::rename(&temp_ext_path, &ext_dir) {
244            // Clean up temp file on failure
245            let _ = std::fs::remove_file(&temp_ext_path);
246            return Err(e).with_context(|| "Failed to rename extension to final destination");
247        }
248
249        // Smoke test: verify the extension loads correctly before enabling it in php.ini.
250        // This prevents broken extensions from crashing PHP on startup.
251        if !self.no_smoke_test {
252            let smoke_test = Command::new("php")
253                .arg("-d")
254                .arg(format!("extension={}", ext_dir.display()))
255                .arg("-r")
256                .arg("")
257                .output()
258                .context("Failed to run PHP for smoke test")?;
259
260            if !smoke_test.status.success() {
261                // Extension failed to load - remove it and report the error
262                let _ = std::fs::remove_file(&ext_dir);
263                let stderr = String::from_utf8_lossy(&smoke_test.stderr);
264                bail!(
265                    "Extension failed to load during smoke test. The extension file has been removed.\n\
266                     PHP output:\n{stderr}"
267                );
268            }
269        }
270
271        if let Some(php_ini) = php_ini {
272            let mut file = OpenOptions::new()
273                .read(true)
274                .write(true)
275                .open(php_ini)
276                .with_context(|| "Failed to open `php.ini`")?;
277
278            let mut ext_line = format!("extension={ext_name}");
279
280            let mut new_lines = vec![];
281            for line in BufReader::new(&file).lines() {
282                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
283                if line.contains(&ext_line) {
284                    bail!("Extension already enabled.");
285                }
286
287                new_lines.push(line);
288            }
289
290            // Comment out extension if user specifies disable flag
291            if self.disable {
292                ext_line.insert(0, ';');
293            }
294
295            new_lines.push(ext_line);
296            file.rewind()?;
297            file.set_len(0)?;
298            file.write(new_lines.join("\n").as_bytes())
299                .with_context(|| "Failed to update `php.ini`")?;
300        }
301
302        Ok(())
303    }
304}
305
306/// Returns the path to the extension directory utilised by the PHP interpreter,
307/// creating it if one was returned but it does not exist.
308fn get_ext_dir() -> AResult<PathBuf> {
309    let cmd = Command::new("php")
310        .arg("-r")
311        .arg("echo ini_get('extension_dir');")
312        .output()
313        .context("Failed to call PHP")?;
314    if !cmd.status.success() {
315        bail!("Failed to call PHP: {cmd:?}");
316    }
317    let stdout = String::from_utf8_lossy(&cmd.stdout);
318    let ext_dir = PathBuf::from(stdout.rsplit('\n').next().unwrap());
319    if !ext_dir.is_dir() {
320        if ext_dir.exists() {
321            bail!(
322                "Extension directory returned from PHP is not a valid directory: {}",
323                ext_dir.display()
324            );
325        }
326
327        std::fs::create_dir(&ext_dir).with_context(|| {
328            format!(
329                "Failed to create extension directory at {}",
330                ext_dir.display()
331            )
332        })?;
333    }
334    Ok(ext_dir)
335}
336
337/// Returns the path to the `php.ini` loaded by the PHP interpreter.
338fn get_php_ini() -> AResult<PathBuf> {
339    let cmd = Command::new("php")
340        .arg("-r")
341        .arg("echo get_cfg_var('cfg_file_path');")
342        .output()
343        .context("Failed to call PHP")?;
344    if !cmd.status.success() {
345        bail!("Failed to call PHP: {cmd:?}");
346    }
347    let stdout = String::from_utf8_lossy(&cmd.stdout);
348    let ini = PathBuf::from(stdout.rsplit('\n').next().unwrap());
349    if !ini.is_file() {
350        bail!(
351            "php.ini does not exist or is not a file at the given path: {}",
352            ini.display()
353        );
354    }
355    Ok(ini)
356}
357
358impl Remove {
359    pub fn handle(self) -> CrateResult {
360        use std::env::consts;
361
362        let artifact = find_ext(self.manifest.as_ref())?;
363
364        let (mut ext_path, mut php_ini) = if let Some(install_dir) = self.install_dir {
365            (install_dir, None)
366        } else {
367            (get_ext_dir()?, Some(get_php_ini()?))
368        };
369
370        if let Some(ini_path) = self.ini_path {
371            php_ini = Some(ini_path);
372        }
373
374        let ext_file = format!(
375            "{}{}{}",
376            consts::DLL_PREFIX,
377            artifact.name.replace('-', "_"),
378            consts::DLL_SUFFIX
379        );
380        ext_path.push(&ext_file);
381
382        if !ext_path.is_file() {
383            bail!("Unable to find extension installed.");
384        }
385
386        if !self.yes
387            && !Confirm::new()
388                .with_prompt(format!(
389                    "Are you sure you want to remove the extension `{}`?",
390                    artifact.name
391                ))
392                .interact()?
393        {
394            bail!("Installation cancelled.");
395        }
396
397        std::fs::remove_file(ext_path).with_context(|| "Failed to remove extension")?;
398
399        if let Some(php_ini) = php_ini.filter(|path| path.is_file()) {
400            let mut file = OpenOptions::new()
401                .read(true)
402                .write(true)
403                .create(true)
404                .truncate(false)
405                .open(php_ini)
406                .with_context(|| "Failed to open `php.ini`")?;
407
408            let mut new_lines = vec![];
409            for line in BufReader::new(&file).lines() {
410                let line = line.with_context(|| "Failed to read line from `php.ini`")?;
411                if !line.contains(&ext_file) {
412                    new_lines.push(line);
413                }
414            }
415
416            file.rewind()?;
417            file.set_len(0)?;
418            file.write(new_lines.join("\n").as_bytes())
419                .with_context(|| "Failed to update `php.ini`")?;
420        }
421
422        Ok(())
423    }
424}
425
426#[cfg(not(windows))]
427impl Stubs {
428    pub fn handle(self) -> CrateResult {
429        use ext_php_rs::describe::ToStub;
430        use std::{borrow::Cow, str::FromStr};
431
432        let ext_path = if let Some(ext_path) = self.ext {
433            ext_path
434        } else {
435            let target = find_ext(self.manifest.as_ref())?;
436            build_ext(
437                &target,
438                false,
439                self.features,
440                self.all_features,
441                self.no_default_features,
442            )?
443            .into()
444        };
445
446        if !ext_path.is_file() {
447            bail!("Invalid extension path given, not a file.");
448        }
449
450        let ext = self::ext::Ext::load(ext_path)?;
451        let result = ext.describe();
452
453        // Ensure extension and CLI `ext-php-rs` versions are compatible.
454        let cli_version = semver::VersionReq::from_str(ext_php_rs::VERSION).with_context(
455            || "Failed to parse `ext-php-rs` version that `cargo php` was compiled with",
456        )?;
457        let ext_version = semver::Version::from_str(result.version).with_context(
458            || "Failed to parse `ext-php-rs` version that your extension was compiled with",
459        )?;
460
461        if !cli_version.matches(&ext_version) {
462            bail!(
463                "Extension was compiled with an incompatible version of `ext-php-rs` - Extension: {ext_version}, CLI: {cli_version}"
464            );
465        }
466
467        let stubs = result
468            .module
469            .to_stub()
470            .with_context(|| "Failed to generate stubs.")?;
471
472        if self.stdout {
473            print!("{stubs}");
474        } else {
475            let out_path = if let Some(out_path) = &self.out {
476                Cow::Borrowed(out_path)
477            } else {
478                let mut cwd = std::env::current_dir()
479                    .with_context(|| "Failed to get current working directory")?;
480                cwd.push(format!("{}.stubs.php", result.module.name));
481                Cow::Owned(cwd)
482            };
483
484            std::fs::write(out_path.as_ref(), &stubs)
485                .with_context(|| "Failed to write stubs to file")?;
486        }
487
488        Ok(())
489    }
490}
491
492/// Attempts to find an extension in the target directory.
493fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
494    // TODO(david): Look for cargo manifest option or env
495    let mut cmd = cargo_metadata::MetadataCommand::new();
496    if let Some(manifest) = manifest {
497        cmd.manifest_path(manifest);
498    }
499
500    let meta = cmd
501        .features(cargo_metadata::CargoOpt::AllFeatures)
502        .exec()
503        .with_context(|| "Failed to call `cargo metadata`")?;
504
505    let package = meta
506        .root_package()
507        .with_context(|| "Failed to retrieve metadata about crate")?;
508
509    let targets: Vec<_> = package
510        .targets
511        .iter()
512        .filter(|target| {
513            target
514                .crate_types
515                .iter()
516                .any(|ty| ty == &CrateType::DyLib || ty == &CrateType::CDyLib)
517        })
518        .collect();
519
520    let target = match targets.len() {
521        0 => bail!("No library targets were found."),
522        1 => targets[0],
523        _ => {
524            let target_names: Vec<_> = targets.iter().map(|target| &target.name).collect();
525            let chosen = Select::new()
526                .with_prompt("There were multiple library targets detected in the project. Which would you like to use?")
527                .items(&target_names)
528                .interact()?;
529            targets[chosen]
530        }
531    };
532
533    Ok(target.clone())
534}
535
536/// Compiles the extension, searching for the given target artifact. If found,
537/// the path to the extension dynamic library is returned.
538///
539/// # Parameters
540///
541/// * `target` - The target to compile.
542/// * `release` - Whether to compile the target in release mode.
543/// * `features` - Optional list of features.
544///
545/// # Returns
546///
547/// The path to the target artifact.
548fn build_ext(
549    target: &Target,
550    release: bool,
551    features: Option<Vec<String>>,
552    all_features: bool,
553    no_default_features: bool,
554) -> AResult<Utf8PathBuf> {
555    let mut cmd = Command::new("cargo");
556    cmd.arg("build")
557        .arg("--message-format=json-render-diagnostics");
558    if release {
559        cmd.arg("--release");
560    }
561    if let Some(features) = features {
562        cmd.arg("--features");
563        for feature in features {
564            cmd.arg(feature);
565        }
566    }
567
568    if all_features {
569        cmd.arg("--all-features");
570    }
571
572    if no_default_features {
573        cmd.arg("--no-default-features");
574    }
575
576    let mut spawn = cmd
577        .stdout(Stdio::piped())
578        .spawn()
579        .with_context(|| "Failed to spawn `cargo build`")?;
580    let reader = BufReader::new(
581        spawn
582            .stdout
583            .take()
584            .with_context(|| "Failed to take `cargo build` stdout")?,
585    );
586
587    let mut artifact = None;
588    for message in cargo_metadata::Message::parse_stream(reader) {
589        let message = message.with_context(|| "Invalid message received from `cargo build`")?;
590        match message {
591            cargo_metadata::Message::CompilerArtifact(a) => {
592                if &a.target == target {
593                    artifact = Some(a);
594                }
595            }
596            cargo_metadata::Message::BuildFinished(b) => {
597                if b.success {
598                    break;
599                }
600
601                bail!("Compilation failed, cancelling installation.")
602            }
603            _ => {}
604        }
605    }
606
607    let artifact = artifact.with_context(|| "Extension artifact was not compiled")?;
608    for file in artifact.filenames {
609        if file.extension() == Some(std::env::consts::DLL_EXTENSION) {
610            return Ok(file);
611        }
612    }
613
614    bail!("Failed to retrieve extension path from artifact")
615}