Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
155 changes: 137 additions & 18 deletions src/formatting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
//!
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -80,8 +81,8 @@ pub(crate) struct ResolvedTool {
pub(crate) enum FormattingStrategy {
/// Run one or more external tools in sequence.
External(Vec<ResolvedTool>),
/// Use the built-in mago-formatter.
BuiltIn,
/// Use the built-in mago-formatter with optional `mago.toml`
BuiltIn(Option<PathBuf>),
/// Formatting is explicitly disabled.
Disabled,
}
Expand Down Expand Up @@ -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.
Expand All @@ -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<String, String> {
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
Expand All @@ -227,6 +231,38 @@ fn format_with_mago(
Ok(bytes_to_str(formatted).to_string())
}

#[derive(Deserialize)]
struct MagoToml {
formatter: Option<MagoFormatterToml>,
}

#[derive(Deserialize)]
struct MagoFormatterToml {
preset: Option<mago_formatter::presets::FormatterPreset>,
#[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<mago_formatter::settings::FormatSettings, String> {
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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = "<?php\necho 'hello' ; \n";
let result = execute_strategy(
&FormattingStrategy::BuiltIn(Some(config_path)),
content,
&PathBuf::from("/tmp/test.php"),
&FormattingConfig::default(),
crate::types::PhpVersion::default(),
);
assert!(result.is_err());
}

#[test]
Expand Down Expand Up @@ -834,7 +937,7 @@ mod tests {
let config = FormattingConfig::default();
let strategy = resolve_strategy(Some(dir.path()), &config, Some(&composer), None);
assert!(
matches!(strategy, FormattingStrategy::BuiltIn),
matches!(strategy, FormattingStrategy::BuiltIn(None)),
"Expected BuiltIn when binary is missing, got {:?}",
strategy,
);
Expand Down Expand Up @@ -947,7 +1050,7 @@ mod tests {
);
// php-cs-fixer is in vendor/bin but NOT in custom-bin.
assert!(
matches!(strategy, FormattingStrategy::BuiltIn),
matches!(strategy, FormattingStrategy::BuiltIn(None)),
"Expected BuiltIn when custom bin dir doesn't have the tool, got {:?}",
strategy,
);
Expand All @@ -965,15 +1068,19 @@ mod tests {
.unwrap();
let config = FormattingConfig::default();
let strategy = resolve_strategy(None, &config, Some(&composer), None);
assert!(matches!(strategy, FormattingStrategy::BuiltIn));
assert!(matches!(strategy, FormattingStrategy::BuiltIn(None)));
}

// ── format_with_mago ────────────────────────────────────────────

#[test]
fn mago_formats_simple_php() {
let input = "<?php\necho 'hello' ; \n";
let result = format_with_mago(input, mago_php_version::PHPVersion::PHP84);
let result = format_with_mago(
input,
mago_php_version::PHPVersion::PHP84,
mago_formatter::settings::FormatSettings::default(),
);
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
let formatted = result.unwrap();
// The formatter should produce valid PHP with normalized spacing.
Expand All @@ -984,15 +1091,23 @@ mod tests {
#[test]
fn mago_returns_error_for_unparseable_php() {
let input = "<?php\nfunction { broken syntax";
let result = format_with_mago(input, mago_php_version::PHPVersion::PHP84);
let result = format_with_mago(
input,
mago_php_version::PHPVersion::PHP84,
mago_formatter::settings::FormatSettings::default(),
);
assert!(result.is_err());
}

#[test]
fn mago_preserves_already_formatted() {
// A well-formatted snippet should round-trip cleanly.
let input = "<?php\n\necho 'hello';\n";
let result = format_with_mago(input, mago_php_version::PHPVersion::PHP84);
let result = format_with_mago(
input,
mago_php_version::PHPVersion::PHP84,
mago_formatter::settings::FormatSettings::default(),
);
assert!(result.is_ok());
let formatted = result.unwrap();
assert_eq!(formatted, input);
Expand All @@ -1001,7 +1116,11 @@ mod tests {
#[test]
fn mago_reformats_messy_class() {
let input = "<?php\n\nnamespace Demo;\nclass User\n{ public function foo(): string\n{\n return \"1a11a\";}\n}\n";
let result = format_with_mago(input, mago_php_version::PHPVersion::PHP84);
let result = format_with_mago(
input,
mago_php_version::PHPVersion::PHP84,
mago_formatter::settings::FormatSettings::default(),
);
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
let formatted = result.unwrap();
assert_ne!(
Expand Down Expand Up @@ -1059,7 +1178,7 @@ mod tests {
let file_path = PathBuf::from("/tmp/test.php");

let result = execute_strategy(
&FormattingStrategy::BuiltIn,
&FormattingStrategy::BuiltIn(None),
content,
&file_path,
&config,
Expand All @@ -1078,7 +1197,7 @@ mod tests {
let file_path = PathBuf::from("/tmp/sandbox.php");

let result = execute_strategy(
&FormattingStrategy::BuiltIn,
&FormattingStrategy::BuiltIn(None),
content,
&file_path,
&config,
Expand Down