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#[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
35pub type CrateResult = AResult<()>;
37
38pub fn run() -> CrateResult {
44 let mut args: Vec<_> = std::env::args().collect();
45
46 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 Install(Install),
74 Remove(Remove),
85 #[cfg(not(windows))]
90 Stubs(Stubs),
91}
92
93#[allow(clippy::struct_excessive_bools)]
94#[derive(Parser)]
95struct Install {
96 #[arg(long)]
99 #[allow(clippy::struct_field_names)]
100 install_dir: Option<PathBuf>,
101 #[arg(long)]
103 ini_path: Option<PathBuf>,
104 #[arg(long)]
107 disable: bool,
108 #[arg(long)]
110 release: bool,
111 #[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 #[clap(long)]
123 yes: bool,
124 #[clap(long)]
126 no_smoke_test: bool,
127}
128
129#[derive(Parser)]
130struct Remove {
131 #[arg(long)]
135 install_dir: Option<PathBuf>,
136 #[arg(long)]
138 ini_path: Option<PathBuf>,
139 #[arg(long)]
142 manifest: Option<PathBuf>,
143 #[clap(long)]
145 yes: bool,
146}
147
148#[cfg(not(windows))]
149#[derive(Parser)]
150struct Stubs {
151 ext: Option<PathBuf>,
154 #[arg(short, long)]
157 out: Option<PathBuf>,
158 #[arg(long, conflicts_with = "out")]
161 stdout: bool,
162 #[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 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 if let Err(e) = std::fs::rename(&temp_ext_path, &ext_dir) {
244 let _ = std::fs::remove_file(&temp_ext_path);
246 return Err(e).with_context(|| "Failed to rename extension to final destination");
247 }
248
249 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 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 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
306fn 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
337fn 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 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
492fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
494 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
536fn 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}