Skip to content

Commit 092c292

Browse files
committed
Add PHPCS diagnostic proxy and update docs
1 parent bba3cc3 commit 092c292

9 files changed

Lines changed: 1236 additions & 58 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,9 +1012,15 @@ Output uses PHPStan's table format (with progress bar and coloured output) so th
10121012

10131013
## Diagnostic Worker Architecture
10141014

1015-
Diagnostics run in a background `tokio::spawn` task so they never block completion, hover, or signature help. The worker is created during `initialized` via `clone_for_diagnostic_worker`, which builds a shallow clone of the `Backend`. All `Arc`-wrapped fields (maps, caches, the notify/pending slot) are shared by `Arc::clone`, so the worker sees every mutation the main `Backend` makes.
1015+
Diagnostics run in three independent background `tokio::spawn` tasks so they never block completion, hover, or signature help:
10161016

1017-
Non-`Arc` fields are snapshotted at spawn time: `php_version`, `vendor_uri_prefixes`, `vendor_dir_paths`, and `config`. These fields are only written during `initialized` (before the worker is spawned) and never change afterwards. If a future feature adds hot-reloading of `.phpantom.toml` or runtime PHP version changes, the worker would need to be notified or re-cloned. This invariant ("init-time fields are write-once") should be verified before adding any post-init mutation to these fields.
1017+
1. **Native diagnostic worker** — collects fast (syntax errors, unused imports, deprecated usage) and slow (unknown classes/members/functions, argument count, implementation errors) diagnostics. Debounces at 500 ms.
1018+
2. **PHPStan worker** — runs PHPStan in editor mode (`--tmp-file` / `--instead-of`). Debounces at 2 000 ms.
1019+
3. **PHPCS worker** — runs PHP_CodeSniffer via `phpcs --report=json` with stdin piping. Debounces at 2 000 ms.
1020+
1021+
Each worker is created during `initialized` via `clone_for_diagnostic_worker`, which builds a shallow clone of the `Backend`. All `Arc`-wrapped fields (maps, caches, the notify/pending slots) are shared by `Arc::clone`, so every worker sees all mutations the main `Backend` makes. The PHPStan and PHPCS workers each have their own notify handle, pending-URI slot, and diagnostic cache so they run independently of each other and of the native diagnostic worker.
1022+
1023+
Non-`Arc` fields are snapshotted at spawn time: `php_version`, `vendor_uri_prefixes`, `vendor_dir_paths`, and `config`. These fields are only written during `initialized` (before the workers are spawned) and never change afterwards. If a future feature adds hot-reloading of `.phpantom.toml` or runtime PHP version changes, the workers would need to be notified or re-cloned. This invariant ("init-time fields are write-once") should be verified before adding any post-init mutation to these fields.
10181024

10191025
## Name Resolution
10201026

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **PHPCS diagnostic proxy.** PHP_CodeSniffer violations are surfaced as LSP diagnostics. When `squizlabs/php_codesniffer` is in `require-dev`, PHPCS is auto-detected via Composer's bin-dir; otherwise it falls back to `$PATH`. Each violation uses the sniff name as the diagnostic code (e.g. `PSR12.Files.FileHeader.MissingPHPVersion`), maps PHPCS error/warning levels to LSP severity, and marks fixable violations in diagnostic data. Runs in a dedicated background worker with the same debounce and single-pending-URI design as the PHPStan proxy. Configurable under `[phpcs]` in `.phpantom.toml` with `command`, `standard`, and `timeout` options.
1213
- **Linked editing ranges.** Place the cursor on a variable and all occurrences within its definition region (from one assignment to the next) enter linked editing mode. Typing a new name updates every occurrence simultaneously without affecting reassigned uses of the same variable name. Ranges exclude the leading `$` sigil so that typing before a variable (e.g. wrapping it in a function call) does not propagate to other occurrences.
1314
- **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 (always false); `catch` with a non-Throwable type or trait; and traits in native type-hint positions. Severity follows PHP semantics: unconditional fatal errors are Error, runtime-conditional failures are Warning.
1415
- **Nested array shape inference from multi-level key assignments.** Assignments like `$b['a']['b'] = 'x'` now produce a nested array shape type (`array{a: array{b: string}}`), enabling array key completion and hover for arrays built incrementally with nested keys. Previously only single-level key assignments were tracked.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ within the same impact tier.
3232
| F9 | [Namespace renaming](todo/lsp-features.md#f9-namespace-renaming) | Medium | Medium |
3333
| A40 | [Convert to instance variable](todo/actions.md#a40-convert-to-instance-variable) | Medium | Medium |
3434
| D10 | [PHPMD diagnostic proxy](todo/diagnostics.md#d10-phpmd-diagnostic-proxy) | Low | Medium |
35-
| D14 | [PHPCS diagnostic proxy](todo/diagnostics.md#d14-phpcs-diagnostic-proxy) | Low | Medium |
3635
| D15 | [Type error diagnostics](todo/diagnostics.md#d15-type-error-diagnostics) (argument, return, property type mismatches) | Medium-High | High |
3736
| | **Release 0.8.0** | | |
3837

docs/todo/diagnostics.md

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -294,38 +294,6 @@ resolution. This eliminates the secondary resolvers entirely.
294294

295295
---
296296

297-
## D14. PHPCS diagnostic proxy
298-
299-
**Impact: Low · Effort: Medium**
300-
301-
Proxy PHP_CodeSniffer (PHPCS) diagnostics into the editor, following
302-
the same pattern as the existing PHPStan proxy. PHPCS reports coding
303-
standard violations (PSR-12, PSR-1, custom sniffs) and is widely used
304-
in PHP projects for enforcing style and detecting common mistakes.
305-
306-
### Implementation
307-
308-
1. Add a `[phpcs]` section to the config schema in `src/config.rs`
309-
with `command` (default `"vendor/bin/phpcs"`), `timeout`,
310-
`standard` (default: project's `phpcs.xml` or `phpcs.xml.dist`,
311-
falling back to `PSR12`), and an `enabled` flag.
312-
2. Run PHPCS with `--report=json` on the current file and parse the
313-
JSON output into LSP diagnostics. Each violation maps to a
314-
diagnostic with the sniff name as the code (e.g.
315-
`PSR12.Files.FileHeader.MissingPHPVersion`).
316-
3. Map PHPCS severity levels (`error` / `warning`) to LSP
317-
`DiagnosticSeverity::Error` and `DiagnosticSeverity::Warning`.
318-
4. Mark fixable violations (PHPCS reports `fixable: true` per
319-
violation) so that a companion code action can run `phpcbf` to
320-
auto-fix them.
321-
5. Respect the same debounce and queueing logic used by the PHPStan
322-
proxy to avoid overwhelming the tool on rapid edits.
323-
6. Support `phpcs:ignore` and `phpcs:disable` / `phpcs:enable`
324-
suppression comments when diagnostic suppression intelligence
325-
(D5) is implemented.
326-
327-
---
328-
329297
## D15. Type error diagnostics
330298

331299
**Impact: Medium-High · Effort: High**

src/config.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub struct Config {
2929
pub formatting: FormattingConfig,
3030
/// PHPStan proxy settings.
3131
pub phpstan: PhpStanConfig,
32+
/// PHPCS (PHP_CodeSniffer) proxy settings.
33+
pub phpcs: PhpcsConfig,
3234
}
3335

3436
/// `[php]` section — PHP version override.
@@ -175,6 +177,47 @@ impl PhpStanConfig {
175177
}
176178
}
177179

180+
/// `[phpcs]` section — PHP_CodeSniffer proxy settings.
181+
///
182+
/// When `command` is unset (`None`), PHPantom auto-detects via
183+
/// `vendor/bin/phpcs` then `$PATH`. Set to `""` (empty string)
184+
/// to explicitly disable PHPCS integration.
185+
#[derive(Debug, Clone, Default, Deserialize)]
186+
#[serde(default)]
187+
pub struct PhpcsConfig {
188+
/// Command (path or name) to run PHPCS.
189+
///
190+
/// - `None` (default) — auto-detect `vendor/bin/phpcs`,
191+
/// then `phpcs` on `$PATH`.
192+
/// - `""` — disable PHPCS.
193+
/// - Any other value — use as the command (e.g.
194+
/// `"vendor/bin/phpcs"` or `"phpcs"`).
195+
pub command: Option<String>,
196+
/// Coding standard to enforce (e.g. `"PSR12"`).
197+
///
198+
/// When unset, PHPCS uses its own default detection
199+
/// (`phpcs.xml` / `phpcs.xml.dist` in the project root,
200+
/// then its built-in default).
201+
pub standard: Option<String>,
202+
/// Maximum runtime in milliseconds before PHPCS is killed.
203+
/// Defaults to 30 000 ms (30 seconds).
204+
pub timeout: Option<u64>,
205+
}
206+
207+
impl PhpcsConfig {
208+
/// Return the configured timeout in milliseconds, falling back to
209+
/// 30 000 ms when unset.
210+
pub fn timeout_ms(&self) -> u64 {
211+
self.timeout.unwrap_or(30_000)
212+
}
213+
214+
/// Whether PHPCS is explicitly disabled (command set to empty
215+
/// string).
216+
pub fn is_disabled(&self) -> bool {
217+
self.command.as_deref() == Some("")
218+
}
219+
}
220+
178221
/// `[indexing]` section — controls how PHPantom discovers classes across
179222
/// the workspace.
180223
#[derive(Debug, Clone, Default, Deserialize)]
@@ -435,6 +478,10 @@ mod tests {
435478
assert!(config.phpstan.memory_limit.is_none());
436479
assert!(config.phpstan.timeout.is_none());
437480
assert_eq!(config.phpstan.timeout_ms(), 60_000);
481+
assert!(config.phpcs.command.is_none());
482+
assert!(config.phpcs.standard.is_none());
483+
assert!(config.phpcs.timeout.is_none());
484+
assert_eq!(config.phpcs.timeout_ms(), 30_000);
438485
}
439486

440487
#[test]
@@ -448,6 +495,7 @@ mod tests {
448495
assert!(config.formatting.php_cs_fixer.is_none());
449496
assert!(config.formatting.phpcbf.is_none());
450497
assert!(config.phpstan.command.is_none());
498+
assert!(config.phpcs.command.is_none());
451499
}
452500

453501
#[test]
@@ -463,6 +511,7 @@ mod tests {
463511
assert!(config.formatting.php_cs_fixer.is_none());
464512
assert!(config.formatting.phpcbf.is_none());
465513
assert!(config.phpstan.command.is_none());
514+
assert!(config.phpcs.command.is_none());
466515
}
467516

468517
#[test]
@@ -621,6 +670,11 @@ timeout = 5000
621670
command = "/usr/local/bin/phpstan"
622671
memory-limit = "2G"
623672
timeout = 30000
673+
674+
[phpcs]
675+
command = "/usr/local/bin/phpcs"
676+
standard = "PSR12"
677+
timeout = 15000
624678
"#,
625679
)
626680
.unwrap();
@@ -641,6 +695,12 @@ timeout = 30000
641695
);
642696
assert_eq!(config.phpstan.memory_limit.as_deref(), Some("2G"));
643697
assert_eq!(config.phpstan.timeout_ms(), 30_000);
698+
assert_eq!(
699+
config.phpcs.command.as_deref(),
700+
Some("/usr/local/bin/phpcs")
701+
);
702+
assert_eq!(config.phpcs.standard.as_deref(), Some("PSR12"));
703+
assert_eq!(config.phpcs.timeout_ms(), 15_000);
644704
}
645705

646706
#[test]
@@ -765,6 +825,53 @@ timeout = 30000
765825
assert!(!config.formatting.is_disabled());
766826
}
767827

828+
#[test]
829+
fn parses_phpcs_command() {
830+
let dir = tempfile::tempdir().unwrap();
831+
let path = dir.path().join(CONFIG_FILE_NAME);
832+
std::fs::write(&path, "[phpcs]\ncommand = \"vendor/bin/phpcs\"\n").unwrap();
833+
let config = load_config(dir.path()).unwrap();
834+
assert_eq!(config.phpcs.command.as_deref(), Some("vendor/bin/phpcs"));
835+
}
836+
837+
#[test]
838+
fn parses_phpcs_standard() {
839+
let dir = tempfile::tempdir().unwrap();
840+
let path = dir.path().join(CONFIG_FILE_NAME);
841+
std::fs::write(&path, "[phpcs]\nstandard = \"PSR1\"\n").unwrap();
842+
let config = load_config(dir.path()).unwrap();
843+
assert_eq!(config.phpcs.standard.as_deref(), Some("PSR1"));
844+
}
845+
846+
#[test]
847+
fn parses_phpcs_timeout() {
848+
let dir = tempfile::tempdir().unwrap();
849+
let path = dir.path().join(CONFIG_FILE_NAME);
850+
std::fs::write(&path, "[phpcs]\ntimeout = 20000\n").unwrap();
851+
let config = load_config(dir.path()).unwrap();
852+
assert_eq!(config.phpcs.timeout_ms(), 20_000);
853+
}
854+
855+
#[test]
856+
fn phpcs_empty_string_disables() {
857+
let dir = tempfile::tempdir().unwrap();
858+
let path = dir.path().join(CONFIG_FILE_NAME);
859+
std::fs::write(&path, "[phpcs]\ncommand = \"\"\n").unwrap();
860+
let config = load_config(dir.path()).unwrap();
861+
assert_eq!(config.phpcs.command.as_deref(), Some(""));
862+
assert!(config.phpcs.is_disabled());
863+
}
864+
865+
#[test]
866+
fn phpcs_defaults() {
867+
let config = Config::default();
868+
assert!(config.phpcs.command.is_none());
869+
assert!(config.phpcs.standard.is_none());
870+
assert!(config.phpcs.timeout.is_none());
871+
assert_eq!(config.phpcs.timeout_ms(), 30_000);
872+
assert!(!config.phpcs.is_disabled());
873+
}
874+
768875
#[test]
769876
fn merge_toml_overlay_wins() {
770877
let mut base: toml::Table = toml::from_str("[php]\nversion = \"8.2\"\n").unwrap();

0 commit comments

Comments
 (0)