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}
125
126#[derive(Parser)]
127struct 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)]
147struct 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
174impl 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
185impl 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.
265fn 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.display()
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.
295fn 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.display()
310        );
311    }
312    Ok(ini)
313}
314
315impl 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))]
384impl 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!(
420                "Extension was compiled with an incompatible version of `ext-php-rs` - Extension: {ext_version}, CLI: {cli_version}"
421            );
422        }
423
424        let stubs = result
425            .module
426            .to_stub()
427            .with_context(|| "Failed to generate stubs.")?;
428
429        if self.stdout {
430            print!("{stubs}");
431        } else {
432            let out_path = if let Some(out_path) = &self.out {
433                Cow::Borrowed(out_path)
434            } else {
435                let mut cwd = std::env::current_dir()
436                    .with_context(|| "Failed to get current working directory")?;
437                cwd.push(format!("{}.stubs.php", result.module.name));
438                Cow::Owned(cwd)
439            };
440
441            std::fs::write(out_path.as_ref(), &stubs)
442                .with_context(|| "Failed to write stubs to file")?;
443        }
444
445        Ok(())
446    }
447}
448
449/// Attempts to find an extension in the target directory.
450fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
451    // TODO(david): Look for cargo manifest option or env
452    let mut cmd = cargo_metadata::MetadataCommand::new();
453    if let Some(manifest) = manifest {
454        cmd.manifest_path(manifest);
455    }
456
457    let meta = cmd
458        .features(cargo_metadata::CargoOpt::AllFeatures)
459        .exec()
460        .with_context(|| "Failed to call `cargo metadata`")?;
461
462    let package = meta
463        .root_package()
464        .with_context(|| "Failed to retrieve metadata about crate")?;
465
466    let targets: Vec<_> = package
467        .targets
468        .iter()
469        .filter(|target| {
470            target
471                .crate_types
472                .iter()
473                .any(|ty| ty == &CrateType::DyLib || ty == &CrateType::CDyLib)
474        })
475        .collect();
476
477    let target = match targets.len() {
478        0 => bail!("No library targets were found."),
479        1 => targets[0],
480        _ => {
481            let target_names: Vec<_> = targets.iter().map(|target| &target.name).collect();
482            let chosen = Select::new()
483                .with_prompt("There were multiple library targets detected in the project. Which would you like to use?")
484                .items(&target_names)
485                .interact()?;
486            targets[chosen]
487        }
488    };
489
490    Ok(target.clone())
491}
492
493/// Compiles the extension, searching for the given target artifact. If found,
494/// the path to the extension dynamic library is returned.
495///
496/// # Parameters
497///
498/// * `target` - The target to compile.
499/// * `release` - Whether to compile the target in release mode.
500/// * `features` - Optional list of features.
501///
502/// # Returns
503///
504/// The path to the target artifact.
505fn build_ext(
506    target: &Target,
507    release: bool,
508    features: Option<Vec<String>>,
509    all_features: bool,
510    no_default_features: bool,
511) -> AResult<Utf8PathBuf> {
512    let mut cmd = Command::new("cargo");
513    cmd.arg("build")
514        .arg("--message-format=json-render-diagnostics");
515    if release {
516        cmd.arg("--release");
517    }
518    if let Some(features) = features {
519        cmd.arg("--features");
520        for feature in features {
521            cmd.arg(feature);
522        }
523    }
524
525    if all_features {
526        cmd.arg("--all-features");
527    }
528
529    if no_default_features {
530        cmd.arg("--no-default-features");
531    }
532
533    let mut spawn = cmd
534        .stdout(Stdio::piped())
535        .spawn()
536        .with_context(|| "Failed to spawn `cargo build`")?;
537    let reader = BufReader::new(
538        spawn
539            .stdout
540            .take()
541            .with_context(|| "Failed to take `cargo build` stdout")?,
542    );
543
544    let mut artifact = None;
545    for message in cargo_metadata::Message::parse_stream(reader) {
546        let message = message.with_context(|| "Invalid message received from `cargo build`")?;
547        match message {
548            cargo_metadata::Message::CompilerArtifact(a) => {
549                if &a.target == target {
550                    artifact = Some(a);
551                }
552            }
553            cargo_metadata::Message::BuildFinished(b) => {
554                if b.success {
555                    break;
556                }
557
558                bail!("Compilation failed, cancelling installation.")
559            }
560            _ => {}
561        }
562    }
563
564    let artifact = artifact.with_context(|| "Extension artifact was not compiled")?;
565    for file in artifact.filenames {
566        if file.extension() == Some(std::env::consts::DLL_EXTENSION) {
567            return Ok(file);
568        }
569    }
570
571    bail!("Failed to retrieve extension path from artifact")
572}