Skip to content

Commit cca3517

Browse files
committed
Add support for vendor classes
1 parent e5906d4 commit cca3517

3 files changed

Lines changed: 346 additions & 3 deletions

File tree

src/composer.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::fs;
12
/// Composer autoload support.
23
///
34
/// This module handles parsing `composer.json` to extract PSR-4 autoload
@@ -32,7 +33,7 @@ pub struct Psr4Mapping {
3233
/// contains no PSR-4 mappings.
3334
pub fn parse_composer_json(workspace_root: &Path) -> Vec<Psr4Mapping> {
3435
let composer_path = workspace_root.join("composer.json");
35-
let content = match std::fs::read_to_string(&composer_path) {
36+
let content = match fs::read_to_string(&composer_path) {
3637
Ok(c) => c,
3738
Err(_) => return Vec::new(),
3839
};
@@ -56,12 +57,126 @@ pub fn parse_composer_json(workspace_root: &Path) -> Vec<Psr4Mapping> {
5657
}
5758
}
5859

60+
// Also load vendor autoload mappings (from composer install output)
61+
let vendor_dir = get_vendor_dir(&json);
62+
let vendor_mappings = parse_vendor_autoload_psr4(workspace_root, &vendor_dir);
63+
mappings.extend(vendor_mappings);
64+
5965
// Sort by prefix length descending so longest-prefix-first matching works
6066
mappings.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len()));
6167

6268
mappings
6369
}
6470

71+
/// Read the configured vendor directory from a parsed `composer.json` value.
72+
///
73+
/// Looks at `config.vendor-dir`; defaults to `"vendor"` when absent.
74+
fn get_vendor_dir(composer_json: &serde_json::Value) -> String {
75+
composer_json
76+
.get("config")
77+
.and_then(|c| c.get("vendor-dir"))
78+
.and_then(|v| v.as_str())
79+
.map(|s| s.trim_end_matches('/').to_string())
80+
.unwrap_or_else(|| "vendor".to_string())
81+
}
82+
83+
/// Parse `<vendor>/composer/autoload_psr4.php` and return PSR-4 mappings.
84+
///
85+
/// This file is generated by `composer install` / `composer dump-autoload`.
86+
/// If the file does not exist (e.g. composer install has not been run) an
87+
/// empty `Vec` is returned — the absence is silently tolerated.
88+
///
89+
/// The file contains lines like:
90+
/// ```text
91+
/// 'Namespace\\' => array($vendorDir . '/org/pkg/src'),
92+
/// 'Other\\' => array($baseDir . '/lib'),
93+
/// ```
94+
///
95+
/// `$vendorDir` is resolved to `<vendor_dir>` (relative to workspace root).
96+
/// `$baseDir` is resolved to the workspace root (i.e. paths are kept relative).
97+
pub fn parse_vendor_autoload_psr4(workspace_root: &Path, vendor_dir: &str) -> Vec<Psr4Mapping> {
98+
let autoload_path = workspace_root
99+
.join(vendor_dir)
100+
.join("composer")
101+
.join("autoload_psr4.php");
102+
103+
let content = match fs::read_to_string(&autoload_path) {
104+
Ok(c) => c,
105+
Err(_) => return Vec::new(),
106+
};
107+
108+
let mut mappings = Vec::new();
109+
110+
for line in content.lines() {
111+
let trimmed = line.trim();
112+
113+
// Match lines of the form: 'Prefix\\' => array(...),
114+
if let Some(rest) = trimmed.strip_prefix('\'')
115+
&& let Some(arrow_pos) = rest.find("' => array(")
116+
{
117+
// Unescape PHP single-quoted string escapes:
118+
// \\ → \
119+
// \' → '
120+
let prefix_raw = rest[..arrow_pos]
121+
.replace("\\\\'", "'")
122+
.replace("\\\\", "\\");
123+
let array_start = arrow_pos + "' => array(".len();
124+
125+
// Find the closing paren
126+
let array_content = if let Some(end) = rest[array_start..].rfind(')') {
127+
&rest[array_start..array_start + end]
128+
} else {
129+
continue;
130+
};
131+
132+
// Normalise the namespace prefix
133+
let normalised_prefix = if prefix_raw.ends_with('\\') {
134+
prefix_raw.clone()
135+
} else if prefix_raw.is_empty() {
136+
String::new()
137+
} else {
138+
format!("{}\\", prefix_raw)
139+
};
140+
141+
// Parse each path entry in the array, separated by commas.
142+
// Entries look like: $vendorDir . '/org/pkg/src'
143+
// or: $baseDir . '/lib'
144+
for entry in array_content.split(',') {
145+
let entry = entry.trim();
146+
if let Some(base_path) = resolve_autoload_path_entry(entry, vendor_dir) {
147+
mappings.push(Psr4Mapping {
148+
prefix: normalised_prefix.clone(),
149+
base_path: normalise_path(&base_path),
150+
});
151+
}
152+
}
153+
}
154+
}
155+
156+
mappings
157+
}
158+
159+
/// Resolve a single path entry from `autoload_psr4.php`.
160+
///
161+
/// Handles `$vendorDir . '/path'` and `$baseDir . '/path'`.
162+
fn resolve_autoload_path_entry(entry: &str, vendor_dir: &str) -> Option<String> {
163+
let entry = entry.trim();
164+
165+
if let Some(rest) = entry.strip_prefix("$vendorDir . '") {
166+
// $vendorDir . '/org/pkg/src'
167+
let path = rest.strip_suffix('\'')?;
168+
let path = path.strip_prefix('/').unwrap_or(path);
169+
Some(format!("{}/{}", vendor_dir, path))
170+
} else if let Some(rest) = entry.strip_prefix("$baseDir . '") {
171+
// $baseDir . '/lib'
172+
let path = rest.strip_suffix('\'')?;
173+
let path = path.strip_prefix('/').unwrap_or(path);
174+
Some(path.to_string())
175+
} else {
176+
None
177+
}
178+
}
179+
65180
/// Extract PSR-4 entries from a single prefix → path(s) pair.
66181
///
67182
/// The value can be either a string (`"src/"`) or an array of strings

src/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ impl LanguageServer for Backend {
7373
self.log(
7474
MessageType::INFO,
7575
format!(
76-
"PHPantomLSP initialized! Loaded {} PSR-4 mapping(s) from composer.json",
76+
"PHPantomLSP initialized! Loaded {} PSR-4 mapping(s)",
7777
mapping_count
7878
),
7979
)

tests/composer.rs

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use phpantom_lsp::composer::{normalise_path, parse_composer_json, resolve_class_path};
1+
use phpantom_lsp::composer::{
2+
normalise_path, parse_composer_json, parse_vendor_autoload_psr4, resolve_class_path,
3+
};
24
use std::fs;
35
use std::path::Path;
46

@@ -318,6 +320,232 @@ fn test_normalise_path_converts_backslashes() {
318320
assert_eq!(normalise_path("src\\Klarna\\"), "src/Klarna/");
319321
}
320322

323+
#[test]
324+
fn test_vendor_autoload_basic() {
325+
let ws = TestWorkspace::new(r#"{"name": "test/project"}"#);
326+
327+
// Create the vendor autoload file
328+
ws.create_php_file(
329+
"vendor/composer/autoload_psr4.php",
330+
r#"<?php
331+
332+
// autoload_psr4.php @generated by Composer
333+
334+
$vendorDir = dirname(__DIR__);
335+
$baseDir = dirname($vendorDir);
336+
337+
return array(
338+
'voku\\' => array($vendorDir . '/voku/portable-ascii/src/voku'),
339+
'Webmozart\\Assert\\' => array($vendorDir . '/webmozart/assert/src'),
340+
);
341+
"#,
342+
);
343+
344+
let mappings = parse_vendor_autoload_psr4(ws.root(), "vendor");
345+
assert_eq!(mappings.len(), 2);
346+
347+
assert_eq!(mappings[0].prefix, "voku\\");
348+
assert_eq!(
349+
mappings[0].base_path,
350+
"vendor/voku/portable-ascii/src/voku/"
351+
);
352+
353+
assert_eq!(mappings[1].prefix, "Webmozart\\Assert\\");
354+
assert_eq!(mappings[1].base_path, "vendor/webmozart/assert/src/");
355+
}
356+
357+
#[test]
358+
fn test_vendor_autoload_multiple_paths_per_prefix() {
359+
let ws = TestWorkspace::new(r#"{"name": "test/project"}"#);
360+
361+
ws.create_php_file(
362+
"vendor/composer/autoload_psr4.php",
363+
r#"<?php
364+
365+
$vendorDir = dirname(__DIR__);
366+
$baseDir = dirname($vendorDir);
367+
368+
return array(
369+
'phpDocumentor\\Reflection\\' => array($vendorDir . '/phpdocumentor/reflection-docblock/src', $vendorDir . '/phpdocumentor/type-resolver/src', $vendorDir . '/phpdocumentor/reflection-common/src'),
370+
);
371+
"#,
372+
);
373+
374+
let mappings = parse_vendor_autoload_psr4(ws.root(), "vendor");
375+
assert_eq!(mappings.len(), 3);
376+
377+
assert!(
378+
mappings
379+
.iter()
380+
.all(|m| m.prefix == "phpDocumentor\\Reflection\\")
381+
);
382+
assert_eq!(
383+
mappings[0].base_path,
384+
"vendor/phpdocumentor/reflection-docblock/src/"
385+
);
386+
assert_eq!(
387+
mappings[1].base_path,
388+
"vendor/phpdocumentor/type-resolver/src/"
389+
);
390+
assert_eq!(
391+
mappings[2].base_path,
392+
"vendor/phpdocumentor/reflection-common/src/"
393+
);
394+
}
395+
396+
#[test]
397+
fn test_vendor_autoload_basedir_entries() {
398+
let ws = TestWorkspace::new(r#"{"name": "test/project"}"#);
399+
400+
ws.create_php_file(
401+
"vendor/composer/autoload_psr4.php",
402+
r#"<?php
403+
404+
$vendorDir = dirname(__DIR__);
405+
$baseDir = dirname($vendorDir);
406+
407+
return array(
408+
'App\\' => array($baseDir . '/src'),
409+
'App\\Tests\\' => array($baseDir . '/tests'),
410+
);
411+
"#,
412+
);
413+
414+
let mappings = parse_vendor_autoload_psr4(ws.root(), "vendor");
415+
assert_eq!(mappings.len(), 2);
416+
417+
assert_eq!(mappings[0].prefix, "App\\");
418+
assert_eq!(mappings[0].base_path, "src/");
419+
420+
assert_eq!(mappings[1].prefix, "App\\Tests\\");
421+
assert_eq!(mappings[1].base_path, "tests/");
422+
}
423+
424+
#[test]
425+
fn test_vendor_autoload_missing_file_returns_empty() {
426+
let ws = TestWorkspace::new(r#"{"name": "test/project"}"#);
427+
// No vendor directory at all — should not panic
428+
let mappings = parse_vendor_autoload_psr4(ws.root(), "vendor");
429+
assert!(mappings.is_empty());
430+
}
431+
432+
#[test]
433+
fn test_vendor_autoload_custom_vendor_dir() {
434+
let ws = TestWorkspace::new(
435+
r#"{
436+
"config": {
437+
"vendor-dir": "php-packages"
438+
}
439+
}"#,
440+
);
441+
442+
ws.create_php_file(
443+
"php-packages/composer/autoload_psr4.php",
444+
r#"<?php
445+
446+
$vendorDir = dirname(__DIR__);
447+
$baseDir = dirname($vendorDir);
448+
449+
return array(
450+
'Monolog\\' => array($vendorDir . '/monolog/monolog/src/Monolog'),
451+
);
452+
"#,
453+
);
454+
455+
// parse_composer_json should pick up the custom vendor-dir and find the vendor autoload
456+
let mappings = parse_composer_json(ws.root());
457+
assert_eq!(mappings.len(), 1);
458+
assert_eq!(mappings[0].prefix, "Monolog\\");
459+
assert_eq!(
460+
mappings[0].base_path,
461+
"php-packages/monolog/monolog/src/Monolog/"
462+
);
463+
}
464+
465+
#[test]
466+
fn test_vendor_autoload_integrated_with_composer_json() {
467+
let ws = TestWorkspace::new(
468+
r#"{
469+
"autoload": {
470+
"psr-4": {
471+
"App\\": "src/"
472+
}
473+
}
474+
}"#,
475+
);
476+
477+
ws.create_php_file(
478+
"vendor/composer/autoload_psr4.php",
479+
r#"<?php
480+
481+
$vendorDir = dirname(__DIR__);
482+
$baseDir = dirname($vendorDir);
483+
484+
return array(
485+
'App\\' => array($baseDir . '/src'),
486+
'Monolog\\' => array($vendorDir . '/monolog/monolog/src/Monolog'),
487+
);
488+
"#,
489+
);
490+
491+
let mappings = parse_composer_json(ws.root());
492+
493+
// Should have App\ from composer.json, plus App\ and Monolog\ from vendor autoload
494+
assert!(mappings.len() >= 2);
495+
496+
// Monolog should be present (from vendor autoload)
497+
let monolog = mappings.iter().find(|m| m.prefix == "Monolog\\");
498+
assert!(monolog.is_some());
499+
assert_eq!(
500+
monolog.unwrap().base_path,
501+
"vendor/monolog/monolog/src/Monolog/"
502+
);
503+
504+
// App\ from composer.json should be present
505+
let app_entries: Vec<_> = mappings.iter().filter(|m| m.prefix == "App\\").collect();
506+
assert!(app_entries.len() >= 1);
507+
assert!(app_entries.iter().any(|m| m.base_path == "src/"));
508+
}
509+
510+
#[test]
511+
fn test_vendor_autoload_resolve_vendor_class() {
512+
let ws = TestWorkspace::new(
513+
r#"{
514+
"autoload": {
515+
"psr-4": {
516+
"App\\": "src/"
517+
}
518+
}
519+
}"#,
520+
);
521+
522+
ws.create_php_file(
523+
"vendor/composer/autoload_psr4.php",
524+
r#"<?php
525+
526+
$vendorDir = dirname(__DIR__);
527+
$baseDir = dirname($vendorDir);
528+
529+
return array(
530+
'Monolog\\' => array($vendorDir . '/monolog/monolog/src/Monolog'),
531+
);
532+
"#,
533+
);
534+
535+
// Create the actual vendor PHP file so resolve_class_path can find it
536+
ws.create_php_file(
537+
"vendor/monolog/monolog/src/Monolog/Logger.php",
538+
"<?php\nnamespace Monolog;\nclass Logger {}\n",
539+
);
540+
541+
let mappings = parse_composer_json(ws.root());
542+
let result = resolve_class_path(&mappings, ws.root(), "Monolog\\Logger");
543+
544+
assert!(result.is_some());
545+
let path = result.unwrap();
546+
assert!(path.ends_with("vendor/monolog/monolog/src/Monolog/Logger.php"));
547+
}
548+
321549
#[test]
322550
fn test_prefix_without_trailing_backslash() {
323551
let ws = TestWorkspace::new(

0 commit comments

Comments
 (0)