Skip to content

Commit 807e33d

Browse files
committed
Add Laravel Pint formatting support with config option
1 parent 4cf1b10 commit 807e33d

3 files changed

Lines changed: 128 additions & 16 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- **Invalid class-like kind diagnostics.** Flags class-like names used in positions where their kind is guaranteed to fail at runtime: `new` on abstract classes, interfaces, traits, or enums; `extends` on a final class, interface, or trait; `implements` with a non-interface; trait `use` with a non-trait; `instanceof` with a trait; `catch` with a non-Throwable type; and traits in type-hint positions.
1919
- **Unused variable diagnostics.** Variables assigned but never read are flagged with hint severity and rendered as dimmed text. Variables named `$_` or prefixed with `$_` are exempt.
2020
- **Mago diagnostic proxy.** Mago lint and analyze diagnostics are surfaced as LSP diagnostics with quick-fix code actions. Configurable under `[mago]` in `.phpantom.toml`.
21+
- **Laravel Pint formatting.** Projects with `laravel/pint` in `require-dev` automatically use Pint for formatting via stdin. Configurable under `[formatting]` in `.phpantom.toml` with `pint = "path"` or `pint = ""` to disable.
2122
- **PHPCS diagnostic proxy.** PHP_CodeSniffer violations are surfaced as LSP diagnostics with severity mapping. Configurable under `[phpcs]` in `.phpantom.toml`.
2223
- **Return type inference from method bodies.** Methods without a declared return type or `@return` docblock now have their return type inferred from `return` statements, improving completion, hover, and diagnostics for untyped code.
2324
- **Closure and arrow function parameter inference.** Untyped closure parameters are inferred from the enclosing call's callable signature, including through method chains that return `static`. Generic type substitution flows through to inferred parameters.

src/config.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ pub struct FormattingConfig {
116116
/// - `""` — disable phpcbf.
117117
/// - Any other value — use as the command.
118118
pub phpcbf: Option<String>,
119+
/// Command (path or name) to run Laravel Pint.
120+
///
121+
/// - `None` (default) — check `require-dev` in `composer.json`;
122+
/// if absent, fall back to the built-in formatter.
123+
/// - `""` — disable pint.
124+
/// - Any other value — use as the command.
125+
pub pint: Option<String>,
119126
/// Maximum runtime in milliseconds before each formatter is killed.
120127
/// Defaults to 10 000 ms (10 seconds). Applied per tool, not
121128
/// for the combined pipeline.
@@ -129,10 +136,12 @@ impl FormattingConfig {
129136
self.timeout.unwrap_or(10_000)
130137
}
131138

132-
/// Whether formatting is entirely disabled (both tools explicitly
139+
/// Whether formatting is entirely disabled (all tools explicitly
133140
/// set to empty strings).
134141
pub fn is_disabled(&self) -> bool {
135-
self.php_cs_fixer.as_deref() == Some("") && self.phpcbf.as_deref() == Some("")
142+
self.php_cs_fixer.as_deref() == Some("")
143+
&& self.phpcbf.as_deref() == Some("")
144+
&& self.pint.as_deref() == Some("")
136145
}
137146
}
138147

@@ -873,10 +882,15 @@ analyze-timeout = 45000
873882
fn formatting_empty_string_disables_tool() {
874883
let dir = tempfile::tempdir().unwrap();
875884
let path = dir.path().join(CONFIG_FILE_NAME);
876-
std::fs::write(&path, "[formatting]\nphp-cs-fixer = \"\"\nphpcbf = \"\"\n").unwrap();
885+
std::fs::write(
886+
&path,
887+
"[formatting]\nphp-cs-fixer = \"\"\nphpcbf = \"\"\npint = \"\"\n",
888+
)
889+
.unwrap();
877890
let config = load_config(dir.path()).unwrap();
878891
assert_eq!(config.formatting.php_cs_fixer.as_deref(), Some(""));
879892
assert_eq!(config.formatting.phpcbf.as_deref(), Some(""));
893+
assert_eq!(config.formatting.pint.as_deref(), Some(""));
880894
assert!(config.formatting.is_disabled());
881895
}
882896

src/formatting.rs

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
//! Built-in formatting with external tool override.
22
//!
33
//! PHPantom ships a built-in PHP formatter (mago-formatter) that works
4-
//! out of the box. Projects that depend on php-cs-fixer or
5-
//! PHP_CodeSniffer in their `composer.json` `require-dev` automatically
6-
//! use those tools instead. Users can also override tool paths or
7-
//! disable formatting entirely via `.phpantom.toml`.
4+
//! out of the box. Projects that depend on Laravel Pint, php-cs-fixer,
5+
//! or PHP_CodeSniffer in their `composer.json` `require-dev`
6+
//! automatically use those tools instead. Users can also override tool
7+
//! paths or disable formatting entirely via `.phpantom.toml`.
88
//!
99
//! ## Resolution strategy
1010
//!
1111
//! 1. **Explicit config wins.** If the user sets a tool path in
1212
//! `.phpantom.toml`, use that tool. If they set it to `""`, that
1313
//! tool is disabled.
1414
//! 2. **Composer `require-dev` wins over built-in.** If
15-
//! `composer.json` lists `friendsofphp/php-cs-fixer` or
16-
//! `squizlabs/php_codesniffer` in `require-dev`, resolve the binary
17-
//! via Composer's bin-dir and run it as a subprocess.
15+
//! `composer.json` lists `laravel/pint`,
16+
//! `friendsofphp/php-cs-fixer`, or `squizlabs/php_codesniffer` in
17+
//! `require-dev`, resolve the binary via Composer's bin-dir and run
18+
//! it as a subprocess.
1819
//! 3. **Otherwise, use mago-formatter.** No subprocess, no temp files,
1920
//! no external dependencies. Uses PER-CS 2.0 defaults.
2021
//!
@@ -23,9 +24,11 @@
2324
//! ```toml
2425
//! [formatting]
2526
//! # Explicit path: always use this tool, skip require-dev detection.
27+
//! # pint = "/usr/local/bin/pint"
2628
//! # php-cs-fixer = "/usr/local/bin/php-cs-fixer"
2729
//!
2830
//! # Empty string: disable this tool entirely.
31+
//! # pint = ""
2932
//! # php-cs-fixer = ""
3033
//!
3134
//! # Omitted (default): check require-dev, then fall back to
@@ -37,11 +40,12 @@
3740
//!
3841
//! ## Config file discovery
3942
//!
40-
//! Both external tools discover their project config by walking up from
41-
//! the file being formatted. To ensure the project's style rules are
42-
//! applied, formatting runs on a sibling temp file in the same
43-
//! directory as the original so that config walkers
44-
//! (`.php-cs-fixer.php`, `.phpcs.xml`, etc.) find the project rules.
43+
//! External tools discover their project config by walking up from
44+
//! the file being formatted. File-based tools (php-cs-fixer, phpcbf)
45+
//! run on a sibling temp file in the same directory as the original so
46+
//! that config walkers (`.php-cs-fixer.php`, `.phpcs.xml`, etc.) find
47+
//! the project rules. Pint uses `--stdin-filename` to achieve the
48+
//! same config discovery without temp files.
4549
4650
use std::borrow::Cow;
4751
use std::io::Write;
@@ -105,9 +109,18 @@ pub(crate) fn resolve_strategy(
105109
// Check for explicit config overrides first.
106110
let fixer_explicit = matches!(config.php_cs_fixer.as_deref(), Some(s) if !s.is_empty());
107111
let phpcbf_explicit = matches!(config.phpcbf.as_deref(), Some(s) if !s.is_empty());
112+
let pint_explicit = matches!(config.pint.as_deref(), Some(s) if !s.is_empty());
108113

109-
if fixer_explicit || phpcbf_explicit {
114+
if fixer_explicit || phpcbf_explicit || pint_explicit {
110115
let mut tools = Vec::new();
116+
if let Some(cmd) = config.pint.as_deref()
117+
&& !cmd.is_empty()
118+
{
119+
tools.push(ResolvedTool {
120+
name: "pint",
121+
path: PathBuf::from(cmd),
122+
});
123+
}
111124
if let Some(cmd) = config.php_cs_fixer.as_deref()
112125
&& !cmd.is_empty()
113126
{
@@ -139,6 +152,14 @@ pub(crate) fn resolve_strategy(
139152
// one tool while leaving the other to auto-detect).
140153
let fixer_disabled = config.php_cs_fixer.as_deref() == Some("");
141154
let phpcbf_disabled = config.phpcbf.as_deref() == Some("");
155+
let pint_disabled = config.pint.as_deref() == Some("");
156+
157+
if !pint_disabled
158+
&& crate::composer::has_require_dev(package, "laravel/pint")
159+
&& let Some(tool) = resolve_from_bin_dir("pint", workspace_root, bin)
160+
{
161+
tools.push(tool);
162+
}
142163

143164
if !fixer_disabled
144165
&& crate::composer::has_require_dev(package, "friendsofphp/php-cs-fixer")
@@ -283,6 +304,7 @@ fn run_tool(
283304
match tool.name {
284305
"php-cs-fixer" => run_php_cs_fixer(&tool.path, content, file_path, timeout),
285306
"phpcbf" => run_phpcbf(&tool.path, content, file_path, timeout),
307+
"pint" => run_pint(&tool.path, content, file_path, timeout),
286308
_ => Err(format!("Unknown formatting tool: {}", tool.name)),
287309
}
288310
}
@@ -335,6 +357,76 @@ fn run_php_cs_fixer(
335357
}
336358
}
337359

360+
/// Run Pint via stdin and return the formatted content.
361+
///
362+
/// Command: `<tool> --stdin-filename=<file_path>`
363+
///
364+
/// Pint reads from stdin and writes the formatted output to stdout
365+
/// when `--stdin-filename` is provided.
366+
fn run_pint(
367+
tool_path: &Path,
368+
content: &str,
369+
file_path: &Path,
370+
timeout: Duration,
371+
) -> Result<String, String> {
372+
let mut child = Command::new(tool_path)
373+
.arg(format!("--stdin-filename={}", file_path.display()))
374+
.stdin(Stdio::piped())
375+
.stdout(Stdio::piped())
376+
.stderr(Stdio::piped())
377+
.spawn()
378+
.map_err(|e| format!("Failed to spawn pint: {}", e))?;
379+
380+
if let Some(mut stdin) = child.stdin.take() {
381+
stdin
382+
.write_all(content.as_bytes())
383+
.map_err(|e| format!("Failed to write to pint stdin: {}", e))?;
384+
}
385+
386+
let start = std::time::Instant::now();
387+
loop {
388+
match child.try_wait() {
389+
Ok(Some(status)) => {
390+
let mut stdout = String::new();
391+
if let Some(mut out) = child.stdout.take() {
392+
std::io::Read::read_to_string(&mut out, &mut stdout)
393+
.map_err(|e| format!("Failed to read pint stdout: {}", e))?;
394+
}
395+
396+
let code = status.code().unwrap_or(-1);
397+
if code == 0 {
398+
return Ok(stdout);
399+
}
400+
401+
let mut stderr = String::new();
402+
if let Some(mut err) = child.stderr.take() {
403+
let _ = std::io::Read::read_to_string(&mut err, &mut stderr);
404+
}
405+
return Err(format!(
406+
"pint exited with code {} (stderr: {})",
407+
code,
408+
stderr.trim()
409+
));
410+
}
411+
Ok(None) => {
412+
if start.elapsed() >= timeout {
413+
let _ = child.kill();
414+
let _ = child.wait();
415+
return Err(format!(
416+
"Formatter timed out after {}ms",
417+
timeout.as_millis()
418+
));
419+
}
420+
std::thread::sleep(Duration::from_millis(50));
421+
}
422+
Err(e) => {
423+
let _ = child.kill();
424+
return Err(format!("Error waiting for pint: {}", e));
425+
}
426+
}
427+
}
428+
}
429+
338430
/// Run phpcbf on a sibling temp file and return the formatted content.
339431
///
340432
/// Command: `<tool> --no-colors -q <tempfile>`
@@ -565,6 +657,7 @@ mod tests {
565657
#[test]
566658
fn strategy_both_disabled() {
567659
let config = FormattingConfig {
660+
pint: Some(String::new()),
568661
php_cs_fixer: Some(String::new()),
569662
phpcbf: Some(String::new()),
570663
timeout: None,
@@ -576,6 +669,7 @@ mod tests {
576669
#[test]
577670
fn strategy_explicit_commands() {
578671
let config = FormattingConfig {
672+
pint: None,
579673
php_cs_fixer: Some("/usr/bin/php-cs-fixer".to_string()),
580674
phpcbf: Some("/usr/bin/phpcbf".to_string()),
581675
timeout: None,
@@ -596,6 +690,7 @@ mod tests {
596690
#[test]
597691
fn strategy_one_explicit_one_disabled() {
598692
let config = FormattingConfig {
693+
pint: None,
599694
php_cs_fixer: Some("/usr/bin/php-cs-fixer".to_string()),
600695
phpcbf: Some(String::new()),
601696
timeout: None,
@@ -763,6 +858,7 @@ mod tests {
763858

764859
// User explicitly set a different path.
765860
let config = FormattingConfig {
861+
pint: None,
766862
php_cs_fixer: Some("/opt/php-cs-fixer".to_string()),
767863
phpcbf: Some(String::new()),
768864
timeout: None,
@@ -1000,6 +1096,7 @@ mod tests {
10001096
fn execute_disabled_returns_none() {
10011097
let content = "<?php\necho 'hello';\n";
10021098
let config = FormattingConfig {
1099+
pint: None,
10031100
php_cs_fixer: Some(String::new()),
10041101
phpcbf: Some(String::new()),
10051102
timeout: None,

0 commit comments

Comments
 (0)