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}
125
126#[derive(Parser)]
127struct Remove {
128 #[arg(long)]
132 install_dir: Option<PathBuf>,
133 #[arg(long)]
135 ini_path: Option<PathBuf>,
136 #[arg(long)]
139 manifest: Option<PathBuf>,
140 #[clap(long)]
142 yes: bool,
143}
144
145#[cfg(not(windows))]
146#[derive(Parser)]
147struct Stubs {
148 ext: Option<PathBuf>,
151 #[arg(short, long)]
154 out: Option<PathBuf>,
155 #[arg(long, conflicts_with = "out")]
158 stdout: bool,
159 #[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 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
263fn 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
294fn 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 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
449fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
451 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
493fn 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}