Skip to content

Commit d428e23

Browse files
committed
Basic PSR-0 support
1 parent 72554ca commit d428e23

4 files changed

Lines changed: 237 additions & 2 deletions

File tree

docs/CHANGELOG.md

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

4545
- **Phar archive class resolution.** Classes inside `.phar` archives (e.g. PHPStan's `phpstan.phar`) are now discovered and indexed automatically. During Composer autoload scanning, bootstrap files that reference a phar are detected, the archive is parsed in-process (no PHP runtime needed), and all PHP classes inside are registered for completion, hover, go-to-definition, and diagnostics. Anyone writing PHPStan extensions, custom rules, or dynamic return type extensions now gets full IDE support for the PHPStan API. Only uncompressed phars are supported (the format used by PHPStan and most other phar-distributed tools).
46+
- **PSR-0 autoload support.** Packages that use the legacy PSR-0 autoloading standard (e.g. HTMLPurifier) are now discovered automatically. Composer's `autoload_namespaces.php` is parsed at startup and the listed directories are scanned for classes. Previously these packages were invisible unless the user ran `composer install -o` to dump an optimised classmap.
4647
- **Analyze command.** `phpantom_lsp analyze` scans a Composer project and reports PHPantom's own diagnostics in a PHPStan-like table format. Useful for measuring type coverage across an entire codebase without opening files one by one. Accepts an optional path argument to limit the scan to a single file or directory. Only native diagnostics are reported (no PHPStan, no external tools). Output includes diagnostic identifiers and supports `--severity` filtering and `--no-colour` for CI.
4748
- **Add @throws.** New code action triggered by PHPStan's `missingType.checkedException` diagnostic. When PHPStan reports that a method or function throws a checked exception not documented in `@throws`, the quick-fix inserts a `@throws ShortName` tag into the existing docblock (or creates a new docblock) and adds a `use` import for the exception class when needed. Handles methods, standalone functions, and property hooks. Skips the action when the exception is already documented, already imported, or in the same namespace.
4849
- **Remove @throws.** New code action triggered by PHPStan's `throws.unusedType` (a `@throws` tag for a type that is never thrown) and `throws.notThrowable` (a `@throws` tag for a type that is not a subtype of `Throwable`). The quick-fix removes the offending `@throws` line from the docblock, cleans up orphaned blank separator lines, and removes the entire docblock when it would be empty after removal. Handles FQN, short-name, and leading-backslash variants, as well as single-line docblocks.

docs/todo/bugs.md

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,107 @@ within the same impact tier.
1515

1616
---
1717

18-
No outstanding items.
18+
#### B9. `assert($x instanceof self)` narrowing ignores `self`/`static`/`parent`
19+
20+
| | |
21+
|---|---|
22+
| **Impact** | Medium-High |
23+
| **Effort** | Low |
24+
25+
`try_extract_instanceof` in `completion/types/narrowing.rs` only matches
26+
`Expression::Identifier` for the RHS of an `instanceof` check. When the
27+
RHS is `self`, `static`, or `parent`, the Mago AST produces
28+
`Expression::Self_`, `Expression::Static`, or `Expression::Parent`
29+
instead, which fall through to `None`. This means
30+
`assert($feature instanceof self)` never narrows the variable.
31+
32+
The fix is to add three arms to the `match bin.rhs` block in
33+
`try_extract_instanceof` that map `Self_``"self"`, `Static`
34+
`"static"`, `Parent``"parent"`. The downstream
35+
`apply_instanceof_inclusion` already resolves `"self"` etc. to the
36+
current class via `type_hint_to_classes`.
37+
38+
**Reproduce:** any method with a `BaseCatalogFeature` parameter that
39+
calls `assert($feature instanceof self)` then accesses subclass-only
40+
methods on `$feature`. Also affects Mockery test patterns:
41+
`$mock = $this->mock(X::class); assert($mock instanceof X);`.
42+
43+
**Triage count:** ~30 diagnostics in luxplus/shared (18 BaseCatalogFeature,
44+
11 MockInterface, 1 Elasticsearch).
45+
46+
---
47+
48+
#### B10. Negative narrowing after early return not applied
49+
50+
| | |
51+
|---|---|
52+
| **Impact** | Low-Medium |
53+
| **Effort** | Medium |
54+
55+
After `if ($x instanceof Y) { return; }`, the variable `$x` should be
56+
narrowed to exclude `Y` for all subsequent code in the same scope.
57+
PHPantom does not apply this negative narrowing via early return.
58+
59+
**Reproduce:**
60+
61+
```php
62+
public static function toString(mixed $value): string
63+
{
64+
if ($value instanceof Stringable) {
65+
return $value->__toString();
66+
}
67+
if ($value instanceof BackedEnum) {
68+
$value = $value->value; // PHPantom resolves $value as Stringable here
69+
}
70+
}
71+
```
72+
73+
The diagnostic reports `Property 'value' not found on class 'Stringable'`
74+
because `$value` is still resolved as `Stringable` inside the
75+
`BackedEnum` branch, even though that branch is only reachable when
76+
`$value` is NOT `Stringable`.
77+
78+
Guard-clause narrowing already works for `if (!$x instanceof Y) { return; }`
79+
(positive narrowing after negated check). This is the inverse: positive
80+
check with exit should produce negative narrowing for subsequent code.
81+
82+
**Triage count:** ~2 diagnostics directly, but a general correctness
83+
issue that affects any code using the early-return-after-instanceof
84+
pattern.
85+
86+
---
87+
88+
#### B11. Variables initialized to `null` and conditionally reassigned lose their type
89+
90+
| | |
91+
|---|---|
92+
| **Impact** | Medium |
93+
| **Effort** | Medium |
94+
95+
When a variable is initialized as `$x = null` and then conditionally
96+
reassigned inside a loop or branch (e.g. `$x = $transaction`), PHPantom
97+
resolves `$x` to `null` at the usage site even after a truthiness guard
98+
like `if (!$x) { continue; }` or `if ($x !== null)`.
99+
100+
Three common patterns:
101+
102+
1. **Null-init + loop assignment + guard:**
103+
`$x = null; foreach (...) { if (cond) { $x = $expr; } } if ($x) { $x->method(); }`
104+
105+
2. **Null coalesce + guard:**
106+
`$x = $arr[$key] ?? null; if (!$x) { continue; } $x->property;`
107+
108+
3. **assertNotNull:**
109+
`$day = $arr['key'] ?? null; self::assertNotNull($day); $day->from;`
110+
111+
The root cause is that PHPantom's variable resolution picks the first
112+
(or dominant) assignment and does not consider all assignment sites to
113+
build a union type. For pattern 1, only the `= null` is seen. For
114+
patterns 2 and 3, the `?? null` makes the type nullable but the
115+
subsequent guard or assertion is not recognized as narrowing.
116+
117+
Pattern 3 overlaps with custom assert narrowing (`@phpstan-assert`) which
118+
requires recognizing `assertNotNull` as a type guard.
119+
120+
**Triage count:** ~8 scalar_member_access diagnostics in luxplus/shared
121+
(AltapayGateway, PCNService, CustomerService, CoolrunnerClientTest).

src/composer.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,117 @@ pub fn extract_require_once_paths(content: &str) -> Vec<String> {
433433
paths
434434
}
435435

436+
/// Parse `<vendor>/composer/autoload_namespaces.php` (PSR-0 map) and
437+
/// scan the listed directories for PHP classes.
438+
///
439+
/// PSR-0 is a legacy autoloading standard where underscores in class
440+
/// names map to directory separators. For example, the prefix
441+
/// `HTMLPurifier` with base path `library/` means that the class
442+
/// `HTMLPurifier_Config` lives at `library/HTMLPurifier/Config.php`.
443+
///
444+
/// Composer generates `autoload_namespaces.php` for packages that
445+
/// declare `autoload.psr-0` in their `composer.json`. The file
446+
/// contains lines like:
447+
///
448+
/// ```text
449+
/// 'HTMLPurifier' => array($vendorDir . '/ezyang/htmlpurifier/library'),
450+
/// ```
451+
///
452+
/// Rather than implementing PSR-0 name-to-path resolution (which would
453+
/// require handling underscore splitting, prefix directories, and
454+
/// fallback rules), we simply scan each listed directory with the
455+
/// byte-level class scanner and return a classmap. This gives us the
456+
/// same result as `composer install -o` for PSR-0 packages without
457+
/// requiring the user to run the optimised dump.
458+
///
459+
/// Returns an empty `HashMap` if the file does not exist or has no
460+
/// entries.
461+
pub fn parse_autoload_namespaces(
462+
workspace_root: &Path,
463+
vendor_dir: &str,
464+
) -> HashMap<String, PathBuf> {
465+
let autoload_path = workspace_root
466+
.join(vendor_dir)
467+
.join("composer")
468+
.join("autoload_namespaces.php");
469+
470+
let content = match fs::read_to_string(&autoload_path) {
471+
Ok(c) => c,
472+
Err(_) => return HashMap::new(),
473+
};
474+
475+
// Collect all base directories listed in the PSR-0 map.
476+
// Each line looks like:
477+
// 'Prefix' => array($vendorDir . '/org/pkg/src'),
478+
// 'Prefix' => array($vendorDir . '/org/pkg/src', $baseDir . '/lib'),
479+
let mut base_dirs: Vec<PathBuf> = Vec::new();
480+
481+
for line in content.lines() {
482+
let trimmed = line.trim();
483+
484+
// Skip lines that don't look like array entries.
485+
if !trimmed.contains("=>") {
486+
continue;
487+
}
488+
489+
// Extract everything after `=> array(` or `=> [`
490+
let rhs = if let Some(pos) = trimmed.find("=> array(") {
491+
&trimmed[pos + "=> array(".len()..]
492+
} else if let Some(pos) = trimmed.find("=> [") {
493+
&trimmed[pos + "=> [".len()..]
494+
} else {
495+
continue;
496+
};
497+
498+
// Strip trailing `)`, `],`, etc.
499+
let rhs =
500+
rhs.trim_end_matches(|c: char| c == ')' || c == ']' || c == ',' || c.is_whitespace());
501+
502+
// Split by comma to handle multiple paths per prefix.
503+
for segment in rhs.split(',') {
504+
let segment = segment.trim();
505+
if let Some(relative_path) = resolve_autoload_path_entry(segment, vendor_dir) {
506+
let full_path = workspace_root.join(&relative_path);
507+
if full_path.is_dir() {
508+
base_dirs.push(full_path);
509+
}
510+
}
511+
}
512+
}
513+
514+
// Scan each base directory for PHP class files.
515+
let mut classmap = HashMap::new();
516+
517+
for base_dir in &base_dirs {
518+
scan_directory_for_classes(base_dir, &mut classmap);
519+
}
520+
521+
classmap
522+
}
523+
524+
/// Recursively scan a directory for PHP files and extract class names
525+
/// using the lightweight byte-level scanner.
526+
fn scan_directory_for_classes(dir: &Path, classmap: &mut HashMap<String, PathBuf>) {
527+
let entries = match fs::read_dir(dir) {
528+
Ok(e) => e,
529+
Err(_) => return,
530+
};
531+
532+
for entry in entries.flatten() {
533+
let path = entry.path();
534+
if path.is_dir() {
535+
scan_directory_for_classes(&path, classmap);
536+
} else if path.extension().is_some_and(|ext| ext == "php")
537+
&& let Ok(content) = fs::read(&path)
538+
{
539+
let classes = crate::classmap_scanner::find_classes(&content);
540+
for fqn in classes {
541+
classmap.entry(fqn).or_insert_with(|| path.clone());
542+
}
543+
}
544+
}
545+
}
546+
436547
/// Detect `.phar` archive references in a PHP bootstrap file.
437548
///
438549
/// Scans the file content for patterns like:

src/server.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,21 @@ impl Backend {
10711071
let symbol_count = classmap.len();
10721072
*self.classmap.write() = classmap;
10731073

1074+
// ── PSR-0 (legacy) classmap ─────────────────────────────────
1075+
// Packages that declare `autoload.psr-0` in their composer.json
1076+
// (e.g. HTMLPurifier) are listed in `autoload_namespaces.php`.
1077+
// Scan the listed directories and merge discovered classes into
1078+
// the classmap so they are resolvable via `find_or_load_class`.
1079+
let psr0_cm = composer::parse_autoload_namespaces(root, &vendor_dir);
1080+
if !psr0_cm.is_empty() {
1081+
let count = psr0_cm.len();
1082+
let mut cm = self.classmap.write();
1083+
for (fqn, path) in psr0_cm {
1084+
cm.entry(fqn).or_insert(path);
1085+
}
1086+
tracing::info!("PSR-0: {} classes from autoload_namespaces.php", count);
1087+
}
1088+
10741089
// ── Autoload files ──────────────────────────────────────────
10751090
if let Some(tok) = progress_token {
10761091
self.progress_report(tok, 70, Some("Scanning autoload files".to_string()))
@@ -1188,7 +1203,12 @@ impl Backend {
11881203
// Load the subproject's Composer classmap as a skip set,
11891204
// then self-scan its PSR-4 directories and vendor packages
11901205
// for anything the classmap missed.
1191-
let sub_cm = composer::parse_autoload_classmap(sub_root, vendor_dir);
1206+
let mut sub_cm = composer::parse_autoload_classmap(sub_root, vendor_dir);
1207+
// Merge PSR-0 classes for this subproject.
1208+
let psr0_cm = composer::parse_autoload_namespaces(sub_root, vendor_dir);
1209+
for (fqn, path) in psr0_cm {
1210+
sub_cm.entry(fqn).or_insert(path);
1211+
}
11921212
let sub_skip: HashSet<PathBuf> = sub_cm.values().cloned().collect();
11931213
let scan = self.build_self_scan_composer(sub_root, vendor_dir, None, &sub_skip);
11941214
self.populate_autoload_indices(&scan);

0 commit comments

Comments
 (0)