Skip to content

Commit a651afb

Browse files
committed
Add support for Composer classmap and basic class name completion
1 parent 2323373 commit a651afb

9 files changed

Lines changed: 1640 additions & 24 deletions

File tree

example.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,4 +646,4 @@ function handleIntersection(User&Loggable $entity): void
646646
$l->getEmail(); // negated → User
647647
} else {
648648
$l->grantPermission('y'); // negated else → AdminUser
649-
}
649+
}

src/completion/builder.rs

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
///
33
/// This module contains the logic for constructing LSP `CompletionItem`s from
44
/// resolved `ClassInfo`, filtered by the `AccessKind` (arrow, double-colon,
5-
/// or parent double-colon).
5+
/// or parent double-colon), as well as class name completion when no member
6+
/// access operator is present.
7+
use std::collections::{HashMap, HashSet};
8+
69
use tower_lsp::lsp_types::*;
710

811
use crate::Backend;
@@ -248,4 +251,195 @@ impl Backend {
248251

249252
items
250253
}
254+
255+
// ─── Class name completion ──────────────────────────────────────────
256+
257+
/// Extract the partial identifier (class name fragment) that the user
258+
/// is currently typing at the given cursor position.
259+
///
260+
/// Walks backward from the cursor through alphanumeric characters,
261+
/// underscores, and backslashes (namespace separators). Returns
262+
/// `None` if the resulting text starts with `$` (variable context)
263+
/// or is empty.
264+
pub fn extract_partial_class_name(
265+
content: &str,
266+
position: Position,
267+
) -> Option<String> {
268+
let lines: Vec<&str> = content.lines().collect();
269+
if position.line as usize >= lines.len() {
270+
return None;
271+
}
272+
273+
let line = lines[position.line as usize];
274+
let chars: Vec<char> = line.chars().collect();
275+
let col = (position.character as usize).min(chars.len());
276+
277+
// Walk backwards through identifier characters (including `\`)
278+
let mut i = col;
279+
while i > 0
280+
&& (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_' || chars[i - 1] == '\\')
281+
{
282+
i -= 1;
283+
}
284+
285+
if i == col {
286+
// Nothing typed — no partial identifier
287+
return None;
288+
}
289+
290+
// If preceded by `$`, this is a variable, not a class name
291+
if i > 0 && chars[i - 1] == '$' {
292+
return None;
293+
}
294+
295+
// If preceded by `->` or `::`, member completion handles this
296+
if i >= 2 && chars[i - 2] == '-' && chars[i - 1] == '>' {
297+
return None;
298+
}
299+
if i >= 2 && chars[i - 2] == ':' && chars[i - 1] == ':' {
300+
return None;
301+
}
302+
303+
let partial: String = chars[i..col].iter().collect();
304+
if partial.is_empty() {
305+
return None;
306+
}
307+
308+
Some(partial)
309+
}
310+
311+
/// Build completion items for class names from all known sources.
312+
///
313+
/// Sources (in priority order):
314+
/// 1. Classes imported via `use` statements in the current file
315+
/// 2. Classes in the same namespace (from the ast_map)
316+
/// 3. Built-in PHP classes from embedded stubs
317+
/// 4. Classes from the Composer classmap (`autoload_classmap.php`)
318+
/// 5. Classes from the class_index (discovered during parsing)
319+
///
320+
/// Each item uses the short class name as `label` and the
321+
/// fully-qualified name as `detail`. Items are deduplicated by FQN.
322+
pub(crate) fn build_class_name_completions(
323+
&self,
324+
file_use_map: &HashMap<String, String>,
325+
file_namespace: &Option<String>,
326+
) -> Vec<CompletionItem> {
327+
let mut seen_fqns: HashSet<String> = HashSet::new();
328+
let mut items: Vec<CompletionItem> = Vec::new();
329+
330+
// ── 1. Use-imported classes (highest priority) ──────────────
331+
for (short_name, fqn) in file_use_map {
332+
if !seen_fqns.insert(fqn.clone()) {
333+
continue;
334+
}
335+
items.push(CompletionItem {
336+
label: short_name.clone(),
337+
kind: Some(CompletionItemKind::CLASS),
338+
detail: Some(fqn.clone()),
339+
insert_text: Some(short_name.clone()),
340+
filter_text: Some(short_name.clone()),
341+
sort_text: Some(format!("0_{}", short_name.to_lowercase())),
342+
..CompletionItem::default()
343+
});
344+
}
345+
346+
// ── 2. Same-namespace classes (from ast_map) ────────────────
347+
if let Some(ns) = file_namespace {
348+
if let Ok(nmap) = self.namespace_map.lock() {
349+
// Find all URIs that share the same namespace
350+
let same_ns_uris: Vec<String> = nmap
351+
.iter()
352+
.filter_map(|(uri, opt_ns)| {
353+
if opt_ns.as_deref() == Some(ns.as_str()) {
354+
Some(uri.clone())
355+
} else {
356+
None
357+
}
358+
})
359+
.collect();
360+
drop(nmap);
361+
362+
if let Ok(amap) = self.ast_map.lock() {
363+
for uri in &same_ns_uris {
364+
if let Some(classes) = amap.get(uri) {
365+
for cls in classes {
366+
let fqn = format!("{}\\{}", ns, cls.name);
367+
if !seen_fqns.insert(fqn.clone()) {
368+
continue;
369+
}
370+
items.push(CompletionItem {
371+
label: cls.name.clone(),
372+
kind: Some(CompletionItemKind::CLASS),
373+
detail: Some(fqn),
374+
insert_text: Some(cls.name.clone()),
375+
filter_text: Some(cls.name.clone()),
376+
sort_text: Some(format!(
377+
"1_{}",
378+
cls.name.to_lowercase()
379+
)),
380+
..CompletionItem::default()
381+
});
382+
}
383+
}
384+
}
385+
}
386+
}
387+
}
388+
389+
// ── 3. Built-in PHP classes from stubs ──────────────────────
390+
for &name in self.stub_index.keys() {
391+
if !seen_fqns.insert(name.to_string()) {
392+
continue;
393+
}
394+
items.push(CompletionItem {
395+
label: name.to_string(),
396+
kind: Some(CompletionItemKind::CLASS),
397+
detail: Some(name.to_string()),
398+
insert_text: Some(name.to_string()),
399+
filter_text: Some(name.to_string()),
400+
sort_text: Some(format!("2_{}", name.to_lowercase())),
401+
..CompletionItem::default()
402+
});
403+
}
404+
405+
// ── 4. Composer classmap ────────────────────────────────────
406+
if let Ok(cmap) = self.classmap.lock() {
407+
for fqn in cmap.keys() {
408+
if !seen_fqns.insert(fqn.clone()) {
409+
continue;
410+
}
411+
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
412+
items.push(CompletionItem {
413+
label: short_name.to_string(),
414+
kind: Some(CompletionItemKind::CLASS),
415+
detail: Some(fqn.clone()),
416+
insert_text: Some(short_name.to_string()),
417+
filter_text: Some(short_name.to_string()),
418+
sort_text: Some(format!("3_{}", short_name.to_lowercase())),
419+
..CompletionItem::default()
420+
});
421+
}
422+
}
423+
424+
// ── 5. class_index (discovered classes) ─────────────────────
425+
if let Ok(idx) = self.class_index.lock() {
426+
for fqn in idx.keys() {
427+
if !seen_fqns.insert(fqn.clone()) {
428+
continue;
429+
}
430+
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
431+
items.push(CompletionItem {
432+
label: short_name.to_string(),
433+
kind: Some(CompletionItemKind::CLASS),
434+
detail: Some(fqn.clone()),
435+
insert_text: Some(short_name.to_string()),
436+
filter_text: Some(short_name.to_string()),
437+
sort_text: Some(format!("3_{}", short_name.to_lowercase())),
438+
..CompletionItem::default()
439+
});
440+
}
441+
}
442+
443+
items
444+
}
251445
}

src/composer.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use std::fs;
23
/// Composer autoload support.
34
///
@@ -160,6 +161,70 @@ pub fn parse_vendor_autoload_psr4(workspace_root: &Path, vendor_dir: &str) -> Ve
160161
mappings
161162
}
162163

164+
/// Parse `<vendor>/composer/autoload_classmap.php` and return a mapping
165+
/// from fully-qualified class name to file path (relative to the workspace
166+
/// root).
167+
///
168+
/// This file is generated by `composer install` / `composer dump-autoload`
169+
/// and maps class names directly to their defining files. When the user
170+
/// runs `composer install -o` (optimised autoloader), Composer converts
171+
/// all PSR-0 and PSR-4 mappings into a classmap, giving us complete
172+
/// coverage of every loadable class.
173+
///
174+
/// The file contains lines like:
175+
/// ```text
176+
/// 'AWS\\CRT\\Auth\\AwsCredentials' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/AwsCredentials.php',
177+
/// 'App\\Models\\User' => $baseDir . '/app/Models/User.php',
178+
/// ```
179+
///
180+
/// `$vendorDir` is resolved relative to the workspace root using the
181+
/// configured vendor directory. `$baseDir` is the workspace root itself.
182+
///
183+
/// Returns an empty `HashMap` if the file does not exist or cannot be
184+
/// parsed.
185+
pub fn parse_autoload_classmap(
186+
workspace_root: &Path,
187+
vendor_dir: &str,
188+
) -> HashMap<String, PathBuf> {
189+
let autoload_path = workspace_root
190+
.join(vendor_dir)
191+
.join("composer")
192+
.join("autoload_classmap.php");
193+
194+
let content = match fs::read_to_string(&autoload_path) {
195+
Ok(c) => c,
196+
Err(_) => return HashMap::new(),
197+
};
198+
199+
let mut classmap = HashMap::new();
200+
201+
for line in content.lines() {
202+
let trimmed = line.trim();
203+
204+
// Match lines of the form:
205+
// 'Fully\\Qualified\\ClassName' => $vendorDir . '/path/to/File.php',
206+
// 'Fully\\Qualified\\ClassName' => $baseDir . '/path/to/File.php',
207+
if let Some(rest) = trimmed.strip_prefix('\'') {
208+
if let Some(arrow_pos) = rest.find("' => ") {
209+
// Unescape PHP single-quoted string escapes:
210+
// \\ → \
211+
// \' → '
212+
let class_name = rest[..arrow_pos]
213+
.replace("\\\\'", "'")
214+
.replace("\\\\", "\\");
215+
216+
let rhs = rest[arrow_pos + "' => ".len()..].trim().trim_end_matches(',');
217+
218+
if let Some(relative_path) = resolve_autoload_path_entry(rhs, vendor_dir) {
219+
classmap.insert(class_name, workspace_root.join(&relative_path));
220+
}
221+
}
222+
}
223+
}
224+
225+
classmap
226+
}
227+
163228
/// Resolve a single path entry from `autoload_psr4.php`.
164229
///
165230
/// Handles `$vendorDir . '/path'` and `$baseDir . '/path'`.

src/lib.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! - [`types`]: Data structures for extracted PHP information (classes, methods, functions, etc.)
66
//! - [`parser`]: PHP parsing and AST extraction using mago_syntax
77
//! - [`completion`]: Completion logic (target extraction, type resolution, item building)
8-
//! - [`composer`]: Composer autoload (PSR-4) parsing and class-to-file resolution
8+
//! - [`composer`]: Composer autoload (PSR-4, classmap) parsing and class-to-file resolution
99
//! - [`server`]: The LSP `LanguageServer` trait implementation
1010
//! - [`util`]: Utility helpers (position conversion, class lookup, logging)
1111
//! - [`definition`]: Go-to-definition support for classes, members, and functions
@@ -85,6 +85,17 @@ pub struct Backend {
8585
/// Populated during `update_ast` (using the file's namespace + class
8686
/// short name) and during server initialization for autoload files.
8787
pub class_index: Arc<Mutex<HashMap<String, String>>>,
88+
/// Composer classmap: fully-qualified class name → file path on disk.
89+
///
90+
/// Parsed from `<vendor>/composer/autoload_classmap.php` during server
91+
/// initialization. This provides a direct FQN-to-file lookup that
92+
/// covers classes not discoverable via PSR-4 — and when the user runs
93+
/// `composer install -o`, Composer converts *all* PSR-0/PSR-4
94+
/// mappings into a classmap, giving complete class coverage.
95+
///
96+
/// Consulted by `find_or_load_class` as a resolution step between
97+
/// the ast_map scan (Phase 1) and PSR-4 resolution (Phase 2).
98+
pub classmap: Arc<Mutex<HashMap<String, PathBuf>>>,
8899
/// Embedded PHP stubs for built-in classes/interfaces (e.g. `UnitEnum`,
89100
/// `BackedEnum`, `Iterator`, `Countable`, …).
90101
/// Maps class short name → raw PHP source code.
@@ -123,6 +134,7 @@ impl Backend {
123134
namespace_map: Arc::new(Mutex::new(HashMap::new())),
124135
global_functions: Arc::new(Mutex::new(HashMap::new())),
125136
class_index: Arc::new(Mutex::new(HashMap::new())),
137+
classmap: Arc::new(Mutex::new(HashMap::new())),
126138
stub_index: stubs::build_stub_class_index(),
127139
stub_function_index: stubs::build_stub_function_index(),
128140
stub_constant_index: stubs::build_stub_constant_index(),
@@ -143,6 +155,7 @@ impl Backend {
143155
namespace_map: Arc::new(Mutex::new(HashMap::new())),
144156
global_functions: Arc::new(Mutex::new(HashMap::new())),
145157
class_index: Arc::new(Mutex::new(HashMap::new())),
158+
classmap: Arc::new(Mutex::new(HashMap::new())),
146159
stub_index: stubs::build_stub_class_index(),
147160
stub_function_index: stubs::build_stub_function_index(),
148161
stub_constant_index: stubs::build_stub_constant_index(),
@@ -166,6 +179,7 @@ impl Backend {
166179
namespace_map: Arc::new(Mutex::new(HashMap::new())),
167180
global_functions: Arc::new(Mutex::new(HashMap::new())),
168181
class_index: Arc::new(Mutex::new(HashMap::new())),
182+
classmap: Arc::new(Mutex::new(HashMap::new())),
169183
stub_index,
170184
stub_function_index: stubs::build_stub_function_index(),
171185
stub_constant_index: stubs::build_stub_constant_index(),
@@ -194,6 +208,7 @@ impl Backend {
194208
namespace_map: Arc::new(Mutex::new(HashMap::new())),
195209
global_functions: Arc::new(Mutex::new(HashMap::new())),
196210
class_index: Arc::new(Mutex::new(HashMap::new())),
211+
classmap: Arc::new(Mutex::new(HashMap::new())),
197212
stub_index,
198213
stub_function_index,
199214
stub_constant_index,
@@ -218,6 +233,7 @@ impl Backend {
218233
namespace_map: Arc::new(Mutex::new(HashMap::new())),
219234
global_functions: Arc::new(Mutex::new(HashMap::new())),
220235
class_index: Arc::new(Mutex::new(HashMap::new())),
236+
classmap: Arc::new(Mutex::new(HashMap::new())),
221237
stub_index: stubs::build_stub_class_index(),
222238
stub_function_index: stubs::build_stub_function_index(),
223239
stub_constant_index: stubs::build_stub_constant_index(),

0 commit comments

Comments
 (0)