From 844cc93b90b18cdef294a63d0a6f037a5a440ea2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:00:33 +0800 Subject: [PATCH] feat(php): reformat PHP files with the project's PHP-CS-Fixer configuration (#1606) When a .php file has a .php-cs-fixer.php or .php-cs-fixer.dist.php above it and vendor/bin/php-cs-fixer is installed, Reformat Code and format on save run PHP-CS-Fixer on a temporary copy of the unsaved text with that configuration and apply the result as edits. PHP comes from the PHP interpreter setting, or PATH. A selection formats the whole file, with a note. Without a configuration, the language server keeps formatting. --- crates/ide-host/src/formatter.rs | 108 ++++++++++++++++++++++++++++++ crates/ide-tools/src/formatter.rs | 45 +++++++++++++ 2 files changed, 153 insertions(+) diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 3e6862dcf..fea37d053 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -92,6 +92,33 @@ fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRan } } +/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's +/// name (from stdin it doesn't fix anything), so the file on disk stays as it is. +fn php_cs_fixer_edits( + tools: &Tools, + php: Option<&Path>, + fixer: &formatter::PhpCsFixer, + path: &Path, + unsaved: &str, +) -> IdeResult> { + let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?; + let dir = tempfile::tempdir()?; + let copy = dir.path().join(name); + std::fs::write(©, unsaved)?; + formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, ©)?, &Default::default())?; + let formatted = std::fs::read_to_string(©)?; + let text = ide_text::Document::new(unsaved); + Ok(text + .diff(&formatted) + .changes + .into_iter() + .map(|c| TextEdit { + range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)), + new_text: c.insert, + }) + .collect()) +} + pub async fn format( host: &Host, window: &str, @@ -103,6 +130,34 @@ pub async fn format( let snap = project.documents.snapshot(doc)?; let settings = project.settings.effective(); let language = project.languages.get(&snap.language); + // A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files. + if options.tool.is_none() + && snap.path.extension().is_some_and(|e| e == "php") + && let Some(fixer) = formatter::php_cs_fixer(&snap.path) + { + if options.on_save && !settings.editor.format_on_save { + return Ok(Vec::new()); + } + host.require_trust(window)?; + if snap.read_only { + return Err(IdeError::invalid("The file is read-only")); + } + let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter); + let tools = Tools::new(&host.local_data_dir); + let edits = tokio::task::spawn_blocking(move || { + php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + if range.is_some() { + project.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Info, + title: "PHP-CS-Fixer".into(), + body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()), + }); + } + return Ok(edits); + } let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?; if let Some(tool) = selected && options.tool.is_none() @@ -273,6 +328,59 @@ mod tests { assert_eq!(rust.message, "oxfmt cannot format Rust files"); } + #[test] + fn php_cs_fixer_needs_a_config_and_the_project_binary() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("src/Http")).unwrap(); + let file = root.join("src/Http/Kernel.php"); + assert_eq!(formatter::php_cs_fixer(&file), None); + std::fs::write(root.join(".php-cs-fixer.dist.php"), "`: rewrites the last argument. + std::fs::write( + &php, + "#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\ + [ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n", + ) + .unwrap(); + std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap(); + let file = root.join("src/Kernel.php"); + let tools = Tools::new(&root.join("tools")); + let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "&2\nexit 16\n").unwrap(); + let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, " &str { crate::registry::find(tool).map_or(tool, |t| t.display) } +/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhpCsFixer { + pub config: PathBuf, + pub bin: PathBuf, +} + +/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as +/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`. +/// `None` without both: Reformat Code keeps the language server. +pub fn php_cs_fixer(file: &Path) -> Option { + let config = file.parent()?.ancestors().find_map(|dir| { + [".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file()) + })?; + let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?; + Some(PhpCsFixer { config, bin }) +} + fn package_bin(package: &Path, tool: &str) -> IdeResult { let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) @@ -159,6 +177,33 @@ pub fn arguments( args } +impl Tools { + /// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the + /// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`. + pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult { + let php = match php { + Some(php) => php.to_path_buf(), + None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?, + }; + let mut cmd = self.command(php); + cmd.arg(&fixer.bin) + .args(["fix", "--config"]) + .arg(&fixer.config) + .args(["--using-cache=no", "--no-interaction", "--show-progress=none"]) + .arg(target); + if let Some(dir) = fixer.config.parent() { + cmd.current_dir(dir); + } + Ok(cmd) + } +} + +/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place. +pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel) + .map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim()))) +} + pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) }