Skip to content

Commit 3cb2caf

Browse files
committed
Fix import of global classes and namespace context
1 parent 7e80b7e commit 3cb2caf

6 files changed

Lines changed: 682 additions & 79 deletions

File tree

src/completion/builder.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,19 @@ pub(crate) fn find_use_insert_position(content: &str) -> Position {
103103
/// Build an `additional_text_edits` entry that inserts a `use` statement
104104
/// for the given fully-qualified class name.
105105
///
106-
/// Returns `None` when the FQN has no namespace separator (e.g. bare
107-
/// `DateTime`), meaning no import is needed.
108-
pub(crate) fn build_use_edit(fqn: &str, insert_pos: Position) -> Option<Vec<TextEdit>> {
109-
// No namespace → no import needed (e.g. `DateTime`, `Iterator`)
110-
if !fqn.contains('\\') {
106+
/// When the FQN has no namespace separator (e.g. `PDO`, `DateTime`),
107+
/// an import is only needed if the current file declares a namespace —
108+
/// otherwise we are already in the global namespace and no `use`
109+
/// statement is required. Returns `None` in that case.
110+
pub(crate) fn build_use_edit(
111+
fqn: &str,
112+
insert_pos: Position,
113+
file_namespace: &Option<String>,
114+
) -> Option<Vec<TextEdit>> {
115+
// No namespace separator → this is a global class (e.g. `PDO`, `DateTime`).
116+
// Only needs an import when the current file declares a namespace;
117+
// otherwise we're already in the global namespace.
118+
if !fqn.contains('\\') && file_namespace.is_none() {
111119
return None;
112120
}
113121

@@ -550,7 +558,7 @@ impl Backend {
550558
insert_text: Some(short_name.to_string()),
551559
filter_text: Some(short_name.to_string()),
552560
sort_text: Some(format!("2_{}", short_name.to_lowercase())),
553-
additional_text_edits: build_use_edit(fqn, use_insert_pos),
561+
additional_text_edits: build_use_edit(fqn, use_insert_pos, file_namespace),
554562
..CompletionItem::default()
555563
});
556564
}
@@ -573,7 +581,7 @@ impl Backend {
573581
insert_text: Some(short_name.to_string()),
574582
filter_text: Some(short_name.to_string()),
575583
sort_text: Some(format!("3_{}", short_name.to_lowercase())),
576-
additional_text_edits: build_use_edit(fqn, use_insert_pos),
584+
additional_text_edits: build_use_edit(fqn, use_insert_pos, file_namespace),
577585
..CompletionItem::default()
578586
});
579587
}
@@ -595,7 +603,7 @@ impl Backend {
595603
insert_text: Some(short_name.to_string()),
596604
filter_text: Some(short_name.to_string()),
597605
sort_text: Some(format!("4_{}", short_name.to_lowercase())),
598-
additional_text_edits: build_use_edit(name, use_insert_pos),
606+
additional_text_edits: build_use_edit(name, use_insert_pos, file_namespace),
599607
..CompletionItem::default()
600608
});
601609
}

src/definition/resolve.rs

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -355,41 +355,57 @@ impl Backend {
355355

356356
// Build a class_loader closure (same pattern as the completion handler).
357357
let class_loader = |name: &str| -> Option<ClassInfo> {
358-
let resolved_name = if !name.contains('\\') {
358+
// ── Fully qualified name (leading `\`) ──────────────
359+
// `\PDO`, `\Couchbase\Cluster` — strip the leading `\`
360+
// and resolve globally. PHP rule: fully qualified names
361+
// always resolve to the name without the leading `\`.
362+
if let Some(stripped) = name.strip_prefix('\\') {
363+
return self.find_or_load_class(stripped);
364+
}
365+
366+
// ── Unqualified name (no `\` at all) ────────────────
367+
if !name.contains('\\') {
368+
// Check the import table first (`use` statements).
359369
if let Some(fqn) = file_use_map.get(name) {
360-
fqn.as_str()
361-
} else if let Some(ref ns) = file_namespace {
362-
let ns_qualified = format!("{}\\{}", ns, name);
363-
if let Some(cls) = self.find_or_load_class(&ns_qualified) {
364-
return Some(cls);
365-
}
366-
name
367-
} else {
368-
name
370+
return self.find_or_load_class(fqn);
369371
}
370-
} else {
371-
// The name contains `\` — check if the first segment
372-
// is a use-map alias (e.g. `OA\Endpoint` where
373-
// `use Swagger\OpenAPI as OA;` maps `OA` →
374-
// `Swagger\OpenAPI`). Expand it to the FQN.
375-
let first_segment = name.split('\\').next().unwrap_or(name);
376-
if let Some(fqn_prefix) = file_use_map.get(first_segment) {
377-
let rest = &name[first_segment.len()..];
378-
let expanded = format!("{}{}", fqn_prefix, rest);
379-
if let Some(cls) = self.find_or_load_class(&expanded) {
380-
return Some(cls);
381-
}
382-
}
383-
// Also try prefixing with the current namespace.
372+
// In a namespace, prepend the current namespace.
373+
// Class names do NOT fall back to global scope —
374+
// unlike functions/constants. See:
375+
// https://www.php.net/manual/en/language.namespaces.fallback.php
384376
if let Some(ref ns) = file_namespace {
385377
let ns_qualified = format!("{}\\{}", ns, name);
386-
if let Some(cls) = self.find_or_load_class(&ns_qualified) {
387-
return Some(cls);
388-
}
378+
return self.find_or_load_class(&ns_qualified);
389379
}
390-
name
391-
};
392-
self.find_or_load_class(resolved_name)
380+
// No namespace — we're in global scope already.
381+
return self.find_or_load_class(name);
382+
}
383+
384+
// ── Qualified name (contains `\`, no leading `\`) ───
385+
// Check if the first segment is a use-map alias
386+
// (e.g. `OA\Endpoint` where `use Swagger\OpenAPI as OA;`
387+
// maps `OA` → `Swagger\OpenAPI`). Expand to FQN.
388+
let first_segment = name.split('\\').next().unwrap_or(name);
389+
if let Some(fqn_prefix) = file_use_map.get(first_segment) {
390+
let rest = &name[first_segment.len()..];
391+
let expanded = format!("{}{}", fqn_prefix, rest);
392+
if let Some(cls) = self.find_or_load_class(&expanded) {
393+
return Some(cls);
394+
}
395+
}
396+
// Prepend current namespace (if any).
397+
if let Some(ref ns) = file_namespace {
398+
let ns_qualified = format!("{}\\{}", ns, name);
399+
if let Some(cls) = self.find_or_load_class(&ns_qualified) {
400+
return Some(cls);
401+
}
402+
}
403+
// Fall back to the name as-is. Qualified names that
404+
// reach here are typically already-resolved FQNs from
405+
// the parser (parent classes, traits, mixins) that
406+
// were resolved by `resolve_parent_class_names` before
407+
// being stored.
408+
self.find_or_load_class(name)
393409
};
394410

395411
// Build a function_loader closure for resolving standalone function

src/server.rs

Lines changed: 45 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -293,51 +293,57 @@ impl LanguageServer for Backend {
293293
// 3. Search the full ast_map
294294
// 4. Load files on demand via PSR-4
295295
let class_loader = |name: &str| -> Option<crate::ClassInfo> {
296-
// If the name has no namespace separator, it might be a
297-
// short name imported via `use`. Resolve it first.
298-
let resolved_name = if !name.contains('\\') {
296+
// ── Fully qualified name (leading `\`) ──────────────
297+
// `\PDO`, `\Couchbase\Cluster` — strip the leading `\`
298+
// and resolve globally. PHP rule: fully qualified names
299+
// always resolve to the name without the leading `\`.
300+
if let Some(stripped) = name.strip_prefix('\\') {
301+
return self.find_or_load_class(stripped);
302+
}
303+
304+
// ── Unqualified name (no `\` at all) ────────────────
305+
if !name.contains('\\') {
306+
// Check the import table first (`use` statements).
299307
if let Some(fqn) = file_use_map.get(name) {
300-
fqn.as_str()
301-
} else if let Some(ref ns) = file_namespace {
302-
// Not in use map — try current namespace
303-
// (e.g. bare `Sibling` inside `namespace Foo\Bar;`
304-
// becomes `Foo\Bar\Sibling`)
305-
// We build a temporary owned string and leak it into
306-
// a short-lived search, so use a two-phase approach:
307-
// first try the namespace-qualified name, then fall
308-
// back to the bare name.
309-
let ns_qualified = format!("{}\\{}", ns, name);
310-
if let Some(cls) = self.find_or_load_class(&ns_qualified) {
311-
return Some(cls);
312-
}
313-
name
314-
} else {
315-
name
316-
}
317-
} else {
318-
// The name contains `\` — check if the first segment
319-
// is a use-map alias (e.g. `OA\Endpoint` where
320-
// `use Swagger\OpenAPI as OA;` maps `OA` →
321-
// `Swagger\OpenAPI`). Expand it to the FQN.
322-
let first_segment = name.split('\\').next().unwrap_or(name);
323-
if let Some(fqn_prefix) = file_use_map.get(first_segment) {
324-
let rest = &name[first_segment.len()..];
325-
let expanded = format!("{}{}", fqn_prefix, rest);
326-
if let Some(cls) = self.find_or_load_class(&expanded) {
327-
return Some(cls);
328-
}
308+
return self.find_or_load_class(fqn);
329309
}
330-
// Also try prefixing with the current namespace.
310+
// In a namespace, prepend the current namespace.
311+
// Class names do NOT fall back to global scope —
312+
// unlike functions/constants. See:
313+
// https://www.php.net/manual/en/language.namespaces.fallback.php
331314
if let Some(ref ns) = file_namespace {
332315
let ns_qualified = format!("{}\\{}", ns, name);
333-
if let Some(cls) = self.find_or_load_class(&ns_qualified) {
334-
return Some(cls);
335-
}
316+
return self.find_or_load_class(&ns_qualified);
336317
}
337-
name
338-
};
318+
// No namespace — we're in global scope already.
319+
return self.find_or_load_class(name);
320+
}
339321

340-
self.find_or_load_class(resolved_name)
322+
// ── Qualified name (contains `\`, no leading `\`) ───
323+
// Check if the first segment is a use-map alias
324+
// (e.g. `OA\Endpoint` where `use Swagger\OpenAPI as OA;`
325+
// maps `OA` → `Swagger\OpenAPI`). Expand to FQN.
326+
let first_segment = name.split('\\').next().unwrap_or(name);
327+
if let Some(fqn_prefix) = file_use_map.get(first_segment) {
328+
let rest = &name[first_segment.len()..];
329+
let expanded = format!("{}{}", fqn_prefix, rest);
330+
if let Some(cls) = self.find_or_load_class(&expanded) {
331+
return Some(cls);
332+
}
333+
}
334+
// Prepend current namespace (if any).
335+
if let Some(ref ns) = file_namespace {
336+
let ns_qualified = format!("{}\\{}", ns, name);
337+
if let Some(cls) = self.find_or_load_class(&ns_qualified) {
338+
return Some(cls);
339+
}
340+
}
341+
// Fall back to the name as-is. Qualified names that
342+
// reach here are typically already-resolved FQNs from
343+
// the parser (parent classes, traits, mixins) that
344+
// were resolved by `resolve_parent_class_names` before
345+
// being stored.
346+
self.find_or_load_class(name)
341347
};
342348

343349
// Build a function_loader closure that looks up standalone

src/util.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ impl Backend {
186186
// so we match against the last segment of the namespace-qualified name.
187187
let last_segment = name.rsplit('\\').next().unwrap_or(name);
188188

189+
// Extract the expected namespace prefix (if any).
190+
// For "Demo\\PDO" → expected_ns = Some("Demo")
191+
// For "PDO" → expected_ns = None (global scope)
192+
let expected_ns: Option<&str> = if name.contains('\\') {
193+
Some(&name[..name.len() - last_segment.len() - 1])
194+
} else {
195+
None
196+
};
197+
189198
// ── Phase 0: Try the class_index for a direct FQN → URI lookup ──
190199
// This handles classes that don't follow PSR-4 conventions, such as
191200
// classes defined in Composer autoload_files.php entries. Using the
@@ -201,9 +210,25 @@ impl Backend {
201210
}
202211

203212
// ── Phase 1: Search all already-parsed files in the ast_map ──
213+
// When the requested name is namespace-qualified (e.g. "Demo\\PDO"),
214+
// only match classes in files whose namespace matches the expected
215+
// prefix. This prevents "Demo\\PDO" from matching the global "PDO"
216+
// stub that was cached under a different URI.
204217
if let Ok(map) = self.ast_map.lock() {
205-
for classes in map.values() {
218+
let nmap = self.namespace_map.lock().ok();
219+
for (uri, classes) in map.iter() {
206220
if let Some(cls) = classes.iter().find(|c| c.name == last_segment) {
221+
// Verify namespace matches when a specific namespace is
222+
// expected.
223+
if let Some(exp_ns) = expected_ns {
224+
let file_ns = nmap
225+
.as_ref()
226+
.and_then(|nm| nm.get(uri))
227+
.and_then(|opt| opt.as_deref());
228+
if file_ns != Some(exp_ns) {
229+
continue;
230+
}
231+
}
207232
return Some(cls.clone());
208233
}
209234
}
@@ -286,7 +311,13 @@ impl Backend {
286311
// (e.g. UnitEnum, BackedEnum). Parse on first access and cache in
287312
// the ast_map under a `phpantom-stub://` URI so subsequent lookups
288313
// hit Phase 1 and skip parsing entirely.
289-
if let Some(&stub_content) = self.stub_index.get(last_segment) {
314+
//
315+
// Stubs live in the global namespace, so skip this phase when the
316+
// caller is looking for a class in a specific namespace (e.g.
317+
// "Demo\\PDO" should NOT match the global PDO stub).
318+
if expected_ns.is_none()
319+
&& let Some(&stub_content) = self.stub_index.get(last_segment)
320+
{
290321
let mut classes = self.parse_php(stub_content);
291322

292323
// Stubs are in the root namespace — use an empty use_map / namespace.

0 commit comments

Comments
 (0)