diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 92965800..2356117d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Laravel route controller method navigation and completion.** Method-name strings inside `Route::controller(X::class)->group(fn(){…})` closures now resolve as references to the controller's methods. Go-to-definition, find-references, rename, hover, and diagnostics all work on the action string (e.g. `Route::patch('cancel', 'cancel')` resolves `'cancel'` to `WorkItemController::cancel()`). Autocompletion inside the action string offers the controller's methods. Handles `->controller()` anywhere in the fluent chain, chained route calls (`->name()`, etc.), and nested groups where an inner `->controller()` shadows the outer one. Contributed by @calebdw. - **Package provenance displayed in hover.** Hovering over a class, method, property, constant, or function now shows a colored badge indicating where the symbol comes from: 🟢 for direct Composer dependencies (e.g. `laravel/framework`), 🟠 for transitive dependencies with an italic *(transitive)* marker, and 🟣 for PHP core/extension symbols. Project-local symbols show no badge. The package name is resolved from `vendor/composer/installed.json`. Closes #228. Contributed by @calebdw. - **Diagnostic ignore rules in `.phpantom.toml`.** A new `[[diagnostics.ignore]]` config section suppresses matching diagnostics project-wide, similar to PHPStan's `ignoreErrors`. Each rule can constrain by file path (glob), message (regex), and/or diagnostic code, so a project can silence known-noisy paths (test fixtures, vendored code with unavailable stubs) without editor-only `@phpantom-ignore` comments scattered through the codebase. +- **Built-in formatter respects `mago.toml`.** When formatting falls back to the embedded formatter, a `mago.toml` at the workspace root is now honoured, applying its `[formatter]` preset and settings instead of the PER-CS 2.0 defaults. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/233. ### Changed diff --git a/docs/SETUP.md b/docs/SETUP.md index 6b95978e..51986620 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -250,10 +250,40 @@ This creates a `.phpantom.toml` in the current directory. Currently supported se # "self" - always self-scan, ignore Composer classmap # "none" - no proactive scanning, Composer classmap only # strategy = "composer" + +[formatting] +# Explicit path to an external formatter: always use this tool and skip +# require-dev auto-detection. Set to "" to disable that tool entirely. +# pint = "/usr/local/bin/pint" +# php-cs-fixer = "/usr/local/bin/php-cs-fixer" +# phpcbf = "/usr/local/bin/phpcbf" + +# Timeout for external formatters, in milliseconds (default: 10000). +# timeout = 10000 ``` The file is optional. When absent, all settings use their defaults. New settings will be added as features land. Unknown keys are silently ignored, so the file is forward-compatible. +### Code Formatting + +PHPantom ships a built-in PHP formatter (mago-formatter) that works out of the box, so `textDocument/formatting` requests are answered without any setup. The formatter is chosen per project in this order: + +1. **Explicit config wins.** A tool path set under `[formatting]` in `.phpantom.toml` (`pint`, `php-cs-fixer`, or `phpcbf`) is always used. Setting a tool to `""` disables it. +2. **Composer `require-dev` wins over the built-in formatter.** If `composer.json` lists `laravel/pint`, `friendsofphp/php-cs-fixer`, or `squizlabs/php_codesniffer` in `require-dev`, PHPantom resolves the binary through Composer's bin-dir and runs it as a subprocess. These tools discover their own project config (`pint.json`, `.php-cs-fixer.php`, `.phpcs.xml`, etc.) as they normally would. +3. **Otherwise, the built-in formatter is used.** + +The built-in formatter defaults to the PER-CS 2.0 style. If a `mago.toml` is present at the workspace root, its `[formatter]` table is honoured instead, so PHPantom formats with the same preset and settings your project already uses with the Mago CLI: + +```toml +# mago.toml +[formatter] +preset = "psr-12" +print-width = 100 +use-tabs = false +``` + +For the full list of `[formatter]` options (presets, brace placement, blank-line handling, casing, and the rest), refer to the upstream Mago documentation: [Formatter configuration reference](https://mago.carthage.software/latest/en/tools/formatter/configuration-reference). + ### Indexing Strategy By default, PHPantom trusts Composer's autoloader to determine which classes exist in your project. This is intentional: it means completions, diagnostics, and go-to-definition reflect what your code will actually see at runtime. Classes that aren't autoloadable don't appear, because using them would be an error. diff --git a/src/formatting.rs b/src/formatting.rs index a6bde80c..a9e0f44b 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -17,7 +17,7 @@ //! `require-dev`, resolve the binary via Composer's bin-dir and run //! it as a subprocess. //! 3. **Otherwise, use mago-formatter.** No subprocess, no temp files, -//! no external dependencies. Uses PER-CS 2.0 defaults. +//! no external dependencies. Uses PER-CS 2.0 defaults or if present `mago.toml`. //! //! ## Configuration (`.phpantom.toml`) //! @@ -53,6 +53,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Duration; +use serde::Deserialize; use tempfile::NamedTempFile; use tower_lsp::lsp_types::{Position, Range, TextEdit}; @@ -80,8 +81,8 @@ pub(crate) struct ResolvedTool { pub(crate) enum FormattingStrategy { /// Run one or more external tools in sequence. External(Vec), - /// Use the built-in mago-formatter. - BuiltIn, + /// Use the built-in mago-formatter with optional `mago.toml` + BuiltIn(Option), /// Formatting is explicitly disabled. Disabled, } @@ -182,7 +183,10 @@ pub(crate) fn resolve_strategy( } // No external tools configured or detected — use built-in. - FormattingStrategy::BuiltIn + let config_path = workspace_root + .filter(|root| crate::mago::has_mago_config(root)) + .map(|root| root.join("mago.toml")); + FormattingStrategy::BuiltIn(config_path) } /// Resolve a tool binary from the Composer bin directory. @@ -208,13 +212,13 @@ fn resolve_from_bin_dir( /// Format PHP source code using the built-in mago-formatter. /// /// Returns the formatted source string, or an error if parsing fails. -/// Uses PER-CS 2.0 style defaults. +/// The caller supplies the effective formatter settings. fn format_with_mago( content: &str, php_version: mago_php_version::PHPVersion, + settings: mago_formatter::settings::FormatSettings, ) -> Result { let arena = bumpalo::Bump::new(); - let settings = mago_formatter::settings::FormatSettings::default(); let formatter = mago_formatter::Formatter::new(&arena, php_version, settings); let formatted = formatter @@ -227,6 +231,38 @@ fn format_with_mago( Ok(bytes_to_str(formatted).to_string()) } +#[derive(Deserialize)] +struct MagoToml { + formatter: Option, +} + +#[derive(Deserialize)] +struct MagoFormatterToml { + preset: Option, + #[serde(flatten)] + settings: mago_formatter::settings::RawFormatSettings, +} + +/// Load the `[formatter]` table from a workspace `mago.toml`. +/// +/// `RawFormatSettings` is Mago's own deserialization type, so settings added +/// by the embedded formatter are accepted without duplicating its schema. +fn load_mago_format_settings( + config_path: &Path, +) -> Result { + let source = std::fs::read_to_string(config_path) + .map_err(|e| format!("Failed to read {}: {}", config_path.display(), e))?; + let config: MagoToml = toml::from_str(&source) + .map_err(|e| format!("Failed to parse {}: {}", config_path.display(), e))?; + + let Some(formatter) = config.formatter else { + return Ok(mago_formatter::settings::FormatSettings::default()); + }; + + let base = formatter.preset.unwrap_or_default().settings(); + Ok(formatter.settings.merge_with(base)) +} + /// Convert a project [`PhpVersion`](crate::types::PhpVersion) into the /// mago-formatter's [`PHPVersion`](mago_php_version::PHPVersion). fn to_mago_php_version(v: crate::types::PhpVersion) -> mago_php_version::PHPVersion { @@ -282,9 +318,13 @@ pub(crate) fn execute_strategy( Ok(Some(edits)) } } - FormattingStrategy::BuiltIn => { + FormattingStrategy::BuiltIn(config_path) => { let mago_version = to_mago_php_version(php_version); - let formatted = format_with_mago(content, mago_version)?; + let settings = match config_path { + Some(config_path) => load_mago_format_settings(config_path)?, + None => mago_formatter::settings::FormatSettings::default(), + }; + let formatted = format_with_mago(content, mago_version, settings)?; let edits = compute_edits(content, &formatted); if edits.is_empty() { Ok(None) @@ -657,7 +697,70 @@ mod tests { fn strategy_default_config_no_composer_is_builtin() { let config = FormattingConfig::default(); let strategy = resolve_strategy(None, &config, None, None); - assert!(matches!(strategy, FormattingStrategy::BuiltIn)); + assert!(matches!(strategy, FormattingStrategy::BuiltIn(None))); + } + + #[test] + fn strategy_builtin_carries_mago_config_when_composer_tools_uninstalled() { + // laravel/pint is declared in require-dev but its binary is not + // present in the tempdir's vendor/bin, so the strategy falls back + // to the built-in formatter — which then picks up the mago.toml. + let dir = tempfile::tempdir().unwrap(); + let config = FormattingConfig::default(); + let composer: crate::composer::ComposerPackage = + serde_json::from_value(serde_json::json!({ + "require-dev": { "laravel/pint": "^1.0" } + })) + .unwrap(); + std::fs::write( + dir.path().join("mago.toml"), + "[formatter]\npreset = \"psr-12\"\n", + ) + .unwrap(); + let strategy = resolve_strategy(Some(dir.path()), &config, Some(&composer), None); + + match strategy { + FormattingStrategy::BuiltIn(path) => { + assert_eq!(path.unwrap(), dir.path().join("mago.toml")) + } + other => panic!("Expected BuiltIn with mago.toml, got {:?}", other), + } + } + + #[test] + fn mago_toml_settings_are_loaded_for_embedded_formatter() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("mago.toml"); + std::fs::write( + &config_path, + "[formatter]\npreset = \"psr-12\"\nprint-width = 80\nuse-tabs = true\n", + ) + .unwrap(); + + let settings = load_mago_format_settings(&config_path).unwrap(); + assert_eq!(settings.print_width, 80); + assert!(settings.use_tabs); + } + + #[test] + fn malformed_mago_toml_returns_error_not_panic() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("mago.toml"); + std::fs::write(&config_path, "this is = not [valid toml").unwrap(); + + let result = load_mago_format_settings(&config_path); + assert!(result.is_err()); + + // A malformed config must surface as a formatting error, never a panic. + let content = "