Skip to content

Commit 72554ca

Browse files
committed
Add basic Phar support
1 parent dc7063f commit 72554ca

7 files changed

Lines changed: 217 additions & 63 deletions

File tree

docs/CHANGELOG.md

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

4343
### Added
4444

45+
- **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).
4546
- **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.
4647
- **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.
4748
- **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.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,6 @@ unlikely to move the needle for most users.
208208
| X2 | Parallel file processing — remaining work | Low-Medium | Medium |
209209
| X5 | Granular progress reporting for indexing, GTI, and Find References | Low-Medium | Medium |
210210
| X4 | Full background indexing (`strategy = "full"`) | Medium | High |
211-
| X8 | [Phar archive class resolution](todo/indexing.md#x8-phar-archive-class-resolution) | Medium | Medium |
212211
| X6 | Disk cache (evaluate later) | Medium | High |
213212
| | **[Inline Completion](todo/inline-completion.md)** | | |
214213
| N1 | Template engine (type-aware snippets) | Medium | High |

docs/todo/indexing.md

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -552,61 +552,4 @@ the implicit signal is sufficient and no explicit tracking is needed.
552552
not essential; the affinity table already provides a good cold-start
553553
ordering.
554554

555-
---
556-
557-
## X8. Phar archive class resolution
558-
559-
**Impact: Medium · Effort: Medium**
560555

561-
Many PHP tools distribute as phar archives even when installed via
562-
Composer. PHPStan is the most prominent example: `composer require
563-
phpstan/phpstan` installs a `phpstan.phar` file, not extracted PHP
564-
source files. Classes like `PHPStan\Type\Type`,
565-
`PHPStan\Reflection\MethodReflection`, and `PHPStan\Analyser\Scope`
566-
are inside the phar and invisible to PHPantom's file-based class
567-
scanner.
568-
569-
This blocks anyone writing PHPStan extensions (custom rules, type
570-
specifying extensions, dynamic return type extensions) from getting
571-
completion, hover, and go-to-definition for the PHPStan API they
572-
are implementing against. It also produces `unknown_class` diagnostics
573-
for every PHPStan type reference.
574-
575-
### What a phar contains
576-
577-
A phar archive is a single file containing a virtual filesystem of
578-
PHP files, accessible at runtime via the `phar://` stream wrapper.
579-
The internal structure mirrors a normal directory tree with
580-
`vendor/autoload.php`, namespaced source files, etc.
581-
582-
### Implementation
583-
584-
1. **Detection.** During Composer autoload scanning, detect when a
585-
PSR-4 or classmap entry points into a `.phar` file (either
586-
directly or via a `bootstrap.php` that includes one). Also scan
587-
`autoload.files` entries that `require` a phar.
588-
589-
2. **Extraction.** Read the phar's file index and extract PHP source
590-
files into an in-memory or temporary directory. The phar format
591-
is documented and relatively simple to parse: a manifest section
592-
lists all files with offsets and sizes, followed by the
593-
concatenated file contents (optionally compressed with gzip or
594-
bzip2).
595-
596-
3. **Indexing.** Feed the extracted PHP files through the existing
597-
class scanner (`find_classes` / `find_symbols`) and add them to
598-
the class index. The source path should use a `phar://` prefix
599-
so go-to-definition can distinguish phar-sourced classes.
600-
601-
4. **Staleness.** Check the phar file's mtime. Re-extract only when
602-
the phar has changed (e.g. after `composer update`).
603-
604-
### Scope
605-
606-
The initial implementation only needs to support uncompressed phars
607-
(which is what PHPStan uses). Compressed phar support (gzip, bzip2)
608-
can be added later if needed.
609-
610-
**Observed:** 13 `unknown_class` diagnostics in `shared` for PHPStan
611-
types, plus 6 cascading `unknown_member` diagnostics from unresolved
612-
PHPStan classes. A user request for this feature also exists.

src/composer.rs

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

436+
/// Detect `.phar` archive references in a PHP bootstrap file.
437+
///
438+
/// Scans the file content for patterns like:
439+
///
440+
/// ```text
441+
/// require 'phar://' . __DIR__ . '/phpstan.phar/vendor/autoload.php';
442+
/// $filepath = 'phar://' . __DIR__ . '/phpstan.phar/src/' . $filename . '.php';
443+
/// ```
444+
///
445+
/// Returns the absolute paths of all `.phar` files referenced in the
446+
/// content, resolved relative to `file_dir` (the directory containing
447+
/// the bootstrap file).
448+
///
449+
/// This is intentionally lenient: it looks for any `__DIR__` + string
450+
/// concatenation that mentions a `.phar` file, regardless of the
451+
/// surrounding PHP structure. False positives are harmless (the phar
452+
/// parser will reject non-phar files), and false negatives are
453+
/// acceptable (users can always extract the phar manually).
454+
pub fn detect_phar_references(content: &str, file_dir: &Path) -> Vec<PathBuf> {
455+
let mut phars = Vec::new();
456+
let mut seen = std::collections::HashSet::new();
457+
458+
for line in content.lines() {
459+
// Look for lines containing both `__DIR__` and `.phar`.
460+
if !line.contains("__DIR__") || !line.contains(".phar") {
461+
continue;
462+
}
463+
464+
// Extract the phar filename from string fragments like:
465+
// '/phpstan.phar/vendor/autoload.php'
466+
// '/phpstan.phar/src/'
467+
// We look for quoted strings containing `.phar` and extract
468+
// the path up to (and including) the `.phar` extension.
469+
for quote in [b'\'', b'"'] {
470+
let bytes = line.as_bytes();
471+
let mut i = 0;
472+
while i < bytes.len() {
473+
if bytes[i] == quote {
474+
// Find the closing quote.
475+
if let Some(end) = bytes[i + 1..].iter().position(|&b| b == quote) {
476+
let fragment = &line[i + 1..i + 1 + end];
477+
if let Some(phar_end) = fragment.find(".phar") {
478+
// Extract the relative path: e.g. "/phpstan.phar"
479+
let rel = &fragment[..phar_end + 5]; // +5 for ".phar"
480+
let rel = rel.trim_start_matches('/');
481+
if !rel.is_empty() {
482+
let phar_path = file_dir.join(rel);
483+
if phar_path.is_file() && seen.insert(phar_path.clone()) {
484+
phars.push(phar_path);
485+
}
486+
}
487+
}
488+
i += 1 + end + 1;
489+
} else {
490+
i += 1;
491+
}
492+
} else {
493+
i += 1;
494+
}
495+
}
496+
}
497+
}
498+
499+
phars
500+
}
501+
436502
/// Discover PHP subproject roots inside a workspace directory.
437503
///
438504
/// When the workspace root itself has no `composer.json`, this function

src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ mod hover;
109109
pub(crate) mod inheritance;
110110
mod inlay_hints;
111111
mod parser;
112+
pub(crate) mod phar;
112113
mod phpstan;
113114
mod references;
114115
mod rename;
@@ -303,6 +304,15 @@ pub struct Backend {
303304
/// Consulted by `find_or_load_class` as a resolution step between
304305
/// the ast_map scan (Phase 1) and PSR-4 resolution (Phase 2).
305306
pub(crate) classmap: Arc<RwLock<HashMap<String, PathBuf>>>,
307+
/// Parsed phar archives keyed by the phar file's absolute path.
308+
///
309+
/// Populated during Composer autoload scanning when a bootstrap file
310+
/// references a `.phar` archive (e.g. PHPStan's `bootstrap.php`).
311+
/// Used by [`parse_and_cache_file`](Self::parse_and_cache_file) to
312+
/// extract PHP source files from inside the archive when the
313+
/// classmap contains a phar-based path (detected by a `!` separator,
314+
/// e.g. `/path/to/phpstan.phar!src/Type/Type.php`).
315+
pub(crate) phar_archives: Arc<RwLock<HashMap<PathBuf, phar::PharArchive>>>,
306316
/// Embedded PHP stubs for built-in classes/interfaces (e.g. `UnitEnum`,
307317
/// `BackedEnum`, `Iterator`, `Countable`, …).
308318
/// Maps class short name → raw PHP source code.
@@ -511,6 +521,7 @@ impl Backend {
511521
fqn_index: Arc::new(RwLock::new(HashMap::new())),
512522
class_not_found_cache: Arc::new(RwLock::new(HashSet::new())),
513523
classmap: Arc::new(RwLock::new(HashMap::new())),
524+
phar_archives: Arc::new(RwLock::new(HashMap::new())),
514525
stub_index: stubs::build_stub_class_index(),
515526
stub_function_index: stubs::build_stub_function_index(),
516527
stub_constant_index: stubs::build_stub_constant_index(),
@@ -712,6 +723,7 @@ impl Backend {
712723
class_index: Arc::clone(&self.class_index),
713724
fqn_index: Arc::clone(&self.fqn_index),
714725
classmap: Arc::clone(&self.classmap),
726+
phar_archives: Arc::clone(&self.phar_archives),
715727
class_not_found_cache: Arc::clone(&self.class_not_found_cache),
716728
stub_index: self.stub_index.clone(),
717729
resolved_class_cache: Arc::clone(&self.resolved_class_cache),

src/resolution.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,39 @@ impl Backend {
174174
None
175175
}
176176

177-
/// Parse a PHP file from disk, cache the results, and return the
178-
/// extracted classes.
177+
/// Parse a PHP file from disk (or from a phar archive), cache the
178+
/// results, and return the extracted classes.
179179
///
180180
/// Convenience wrapper around [`parse_and_cache_content`] that reads
181-
/// the file and derives a `file://` URI from the path. Used by
181+
/// the file and derives a URI from the path. Used by
182182
/// [`find_or_load_class`] (Phases 1.5 and 2) and by the
183183
/// go-to-implementation scanner.
184+
///
185+
/// **Phar support:** when `file_path` contains a `!` separator
186+
/// (e.g. `/path/to/phpstan.phar!src/Type/Type.php`), the left side
187+
/// is the phar archive path and the right side is the internal file
188+
/// path. The content is extracted from the in-memory
189+
/// [`phar_archives`](crate::Backend::phar_archives) cache instead
190+
/// of reading from disk. The URI uses a `phar://` scheme so that
191+
/// go-to-definition can distinguish phar-sourced classes.
184192
pub(crate) fn parse_and_cache_file(&self, file_path: &Path) -> Option<Vec<Arc<ClassInfo>>> {
193+
let path_str = file_path.to_str().unwrap_or_default();
194+
195+
// ── Phar path: "archive.phar!internal/path.php" ─────────
196+
if let Some(sep) = path_str.find('!') {
197+
let phar_path = Path::new(&path_str[..sep]);
198+
let internal_path = &path_str[sep + 1..];
199+
200+
let archives = self.phar_archives.read();
201+
let archive = archives.get(phar_path)?;
202+
let bytes = archive.read_file(internal_path)?;
203+
let content = std::str::from_utf8(bytes).ok()?;
204+
205+
let uri = format!("phar://{}/{}", phar_path.display(), internal_path);
206+
return self.parse_and_cache_content(content, &uri);
207+
}
208+
209+
// ── Regular file path ───────────────────────────────────
185210
let content = std::fs::read_to_string(file_path).ok()?;
186211
let uri = crate::util::path_to_uri(file_path);
187212
self.parse_and_cache_content(&content, &uri)

src/server.rs

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
/// version counter; the worker waits for a quiet period before
2222
/// publishing.
2323
use std::collections::{HashMap, HashSet};
24-
use std::path::PathBuf;
24+
use std::path::{Path, PathBuf};
2525
use std::sync::Arc;
2626
use std::sync::atomic::Ordering;
2727

@@ -38,6 +38,7 @@ use crate::classmap_scanner::{self, WorkspaceScanResult};
3838
use crate::composer;
3939
use crate::config::IndexingStrategy;
4040
use crate::formatting;
41+
use crate::phar;
4142

4243
#[tower_lsp::async_trait]
4344
impl LanguageServer for Backend {
@@ -1365,8 +1366,19 @@ impl Backend {
13651366
}
13661367
}
13671368

1368-
// Follow require_once statements to discover more files.
13691369
let content_str = String::from_utf8_lossy(&content);
1370+
1371+
// ── Phar detection ──────────────────────────────────
1372+
// If this autoload file references a .phar archive,
1373+
// parse the phar and scan its PHP files for classes.
1374+
if let Some(file_dir) = canonical.parent() {
1375+
let phar_paths = composer::detect_phar_references(&content_str, file_dir);
1376+
for phar_path in phar_paths {
1377+
self.scan_phar_archive(&phar_path);
1378+
}
1379+
}
1380+
1381+
// Follow require_once statements to discover more files.
13701382
let require_paths = composer::extract_require_once_paths(&content_str);
13711383
if let Some(file_dir) = canonical.parent() {
13721384
for rel_path in require_paths {
@@ -1389,6 +1401,102 @@ impl Backend {
13891401
autoload_count
13901402
}
13911403

1404+
/// Parse a `.phar` archive and register its PHP classes in the
1405+
/// classmap and class index for lazy loading.
1406+
///
1407+
/// The phar's raw bytes are read from disk, parsed by
1408+
/// [`phar::PharArchive`], and stored in
1409+
/// [`phar_archives`](crate::Backend::phar_archives). Each `.php`
1410+
/// file inside the archive is scanned with the lightweight
1411+
/// [`find_classes`](classmap_scanner::find_classes) byte scanner,
1412+
/// and discovered classes are registered in:
1413+
///
1414+
/// - `classmap` — with a sentinel path like
1415+
/// `/path/to/phpstan.phar!src/Type/Type.php` (the `!` separator
1416+
/// tells [`parse_and_cache_file`](crate::Backend::parse_and_cache_file)
1417+
/// to extract content from the phar instead of reading from disk)
1418+
/// - `class_index` — with a `phar://` URI for completions and
1419+
/// workspace symbols
1420+
fn scan_phar_archive(&self, phar_path: &Path) {
1421+
// Avoid scanning the same phar twice.
1422+
if self.phar_archives.read().contains_key(phar_path) {
1423+
return;
1424+
}
1425+
1426+
let data = match std::fs::read(phar_path) {
1427+
Ok(d) => d,
1428+
Err(_) => return,
1429+
};
1430+
1431+
let archive = match phar::PharArchive::parse(data) {
1432+
Some(a) => a,
1433+
None => {
1434+
tracing::warn!("failed to parse phar archive: {}", phar_path.display());
1435+
return;
1436+
}
1437+
};
1438+
1439+
// Collect PHP file paths first so we can iterate while
1440+
// holding the archive reference.
1441+
let php_files: Vec<String> = archive
1442+
.file_paths()
1443+
.filter(|p| p.ends_with(".php"))
1444+
.map(String::from)
1445+
.collect();
1446+
1447+
let mut classmap_entries: Vec<(String, PathBuf)> = Vec::new();
1448+
let mut class_index_entries: Vec<(String, String)> = Vec::new();
1449+
1450+
for internal_path in &php_files {
1451+
if let Some(content) = archive.read_file(internal_path) {
1452+
let classes = classmap_scanner::find_classes(content);
1453+
for fqn in classes {
1454+
// Sentinel path: "archive.phar!internal/path.php"
1455+
let sentinel =
1456+
PathBuf::from(format!("{}!{}", phar_path.display(), internal_path));
1457+
let phar_uri = format!("phar://{}/{}", phar_path.display(), internal_path);
1458+
classmap_entries.push((fqn.clone(), sentinel));
1459+
class_index_entries.push((fqn, phar_uri));
1460+
}
1461+
}
1462+
}
1463+
1464+
let class_count = classmap_entries.len();
1465+
1466+
// Register classes in the classmap and class_index.
1467+
{
1468+
let mut cm = self.classmap.write();
1469+
for (fqn, path) in classmap_entries {
1470+
cm.entry(fqn).or_insert(path);
1471+
}
1472+
}
1473+
{
1474+
let mut idx = self.class_index.write();
1475+
for (fqn, uri) in class_index_entries {
1476+
idx.entry(fqn).or_insert(uri);
1477+
}
1478+
}
1479+
1480+
// Clear the negative class cache so that classes previously
1481+
// looked up (and cached as "not found") before the phar was
1482+
// scanned can now be resolved.
1483+
if class_count > 0 {
1484+
self.class_not_found_cache.write().clear();
1485+
}
1486+
1487+
tracing::info!(
1488+
"scanned phar {}: {} PHP files, {} classes",
1489+
phar_path.display(),
1490+
php_files.len(),
1491+
class_count,
1492+
);
1493+
1494+
// Store the parsed archive for lazy content extraction.
1495+
self.phar_archives
1496+
.write()
1497+
.insert(phar_path.to_owned(), archive);
1498+
}
1499+
13921500
/// Build a workspace scan by self-scanning a Composer project's
13931501
/// autoload directories (PSR-4 + classmap + vendor packages).
13941502
///

0 commit comments

Comments
 (0)