phpantom_lsp 0.7.0

Fast PHP language server with deep type intelligence. Generics, Laravel, PHPStan annotations. Ready in an instant.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
/// Cross-file class and function resolution.
///
/// This module contains the heavyweight name-resolution logic that is
/// shared by the completion handler, definition resolver, and
/// named-argument resolution.  It was extracted from `util.rs` so that
/// module can focus on simple helper functions.
///
/// # Resolution pipeline
///
/// ## Class resolution ([`Backend::find_or_load_class`])
///
///   0. **Class index** — direct FQN → URI lookup (covers non-PSR-4 classes)
///   1. **ast_map scan** — search all already-parsed files by short name,
///      with namespace verification when a qualified name is requested
///      1.5. **Composer classmap** — `vendor/composer/autoload_classmap.php`
///      direct FQN → file lookup (covers optimised autoloaders)
///   2. **PSR-4 resolution** — convert namespace to file path and parse
///   3. **Embedded stubs** — built-in PHP classes/interfaces bundled in
///      the binary (e.g. `UnitEnum`, `BackedEnum`, `Iterator`)
///
/// ## Function resolution ([`Backend::find_or_load_function`])
///
///   1. **global_functions** — user code + already-cached stubs
///   2. **Embedded stubs** — built-in PHP functions from phpstorm-stubs
///
/// ## Name resolution ([`Backend::resolve_class_name`], [`Backend::resolve_function_name`])
///
///   These methods take a raw name as it appears in source code and resolve
///   it to a concrete `ClassInfo` or `FunctionInfo` using the file's `use`
///   statement mappings and namespace context.  They handle:
///
///   - Fully-qualified names (`\PDO`, `\Couchbase\Cluster`)
///   - Unqualified names resolved via the import table or current namespace
///   - Qualified names with alias expansion and namespace prefixing
use std::collections::HashMap;
use std::sync::Arc;

use std::path::Path;

use crate::Backend;
use crate::composer;
use crate::php_type::PhpType;
use crate::types::{ClassInfo, FileContext, FunctionInfo, PhpVersion};
use crate::util::short_name;

impl Backend {
    /// Try to find a class by name across all cached files in the ast_map,
    /// and if not found, attempt PSR-4 resolution to load the class from disk.
    ///
    /// The `class_name` can be:
    ///   - A simple name like `"Customer"`
    ///   - A namespace-qualified name like `"Klarna\\Customer"`
    ///   - A fully-qualified name like `"\\Klarna\\Customer"` (leading `\` is stripped)
    ///
    /// Returns a shared `Arc<ClassInfo>` if found, or `None`.
    pub(crate) fn find_or_load_class(&self, class_name: &str) -> Option<Arc<ClassInfo>> {
        // Defensively strip nullable prefix (`?Foo` → `Foo`) and generic
        // parameters (`Collection<int, User>` → `Collection`) so that
        // callers don't need to normalise before lookup.
        self.find_or_load_class_typed(&PhpType::parse(class_name))
    }

    /// Like [`find_or_load_class`], but accepts a pre-parsed `PhpType`,
    /// avoiding the redundant `PhpType::parse()` call that the string
    /// overload performs internally.
    pub(crate) fn find_or_load_class_typed(&self, ty: &PhpType) -> Option<Arc<ClassInfo>> {
        let base = ty.base_name()?;
        self.find_or_load_class_inner(base)
    }

    /// Shared implementation used by [`find_or_load_class`].
    /// `class_name` must already be normalised (no `?` prefix, no
    /// generic parameters).
    fn find_or_load_class_inner(&self, class_name: &str) -> Option<Arc<ClassInfo>> {
        // The class name stored in ClassInfo is just the short name (e.g. "Customer"),
        // so we match against the last segment of the namespace-qualified name.
        let last_segment = short_name(class_name);

        // Extract the expected namespace prefix (if any).
        // For "Demo\\PDO" → expected_ns = Some("Demo")
        // For "PDO"       → expected_ns = None (global scope)
        let expected_ns: Option<&str> = if class_name.contains('\\') {
            Some(&class_name[..class_name.len() - last_segment.len() - 1])
        } else {
            None
        };

        // ── Negative cache: skip the full multi-phase search ──
        if self.class_not_found_cache.read().contains(class_name) {
            return None;
        }

        // ── Phase 1: Search all already-parsed files in the ast_map ──
        // Checks short name + namespace to avoid false positives (e.g.
        // "Demo\\PDO" won't match the global "PDO" stub).
        if let Some(cls) = self.find_class_in_ast_map(class_name) {
            return Some(cls);
        }

        // ── Phase 1.5: Try Composer classmap ──
        // The classmap (from `vendor/composer/autoload_classmap.php`) maps
        // FQNs directly to file paths.  This is more targeted than PSR-4
        // (a single hash lookup) and covers classes that don't follow PSR-4
        // conventions.  When the user runs `composer install -o`, *all*
        // classes end up in the classmap, giving complete coverage.
        if let Some(file_path) = self.classmap.read().get(class_name).cloned()
            && let Some(classes) = self.parse_and_cache_file(&file_path)
            && let Some(cls) = classes.iter().find(|c| c.name == last_segment)
        {
            return Some(Arc::clone(cls));
        }

        // ── Phase 2: Try PSR-4 resolution ──
        // PSR-4 mappings come exclusively from composer.json (user code).
        // Vendor code is covered by the classmap (Phase 1.5).  If a
        // vendor class is missing from the classmap, it fails visibly
        // rather than being silently resolved, making stale classmaps
        // obvious (fix: run `composer dump-autoload`).
        if let Some(workspace_root) = self.workspace_root.read().clone() {
            let file_path = {
                let mappings = self.psr4_mappings.read();
                composer::resolve_class_path(&mappings, &workspace_root, class_name)
            };
            if let Some(file_path) = file_path
                && let Some(classes) = self.parse_and_cache_file(&file_path)
                && let Some(cls) = classes.iter().find(|c| c.name == last_segment)
            {
                return Some(Arc::clone(cls));
            }
        }

        // ── Phase 3: Try embedded PHP stubs ──
        // Stubs are bundled in the binary for built-in classes/interfaces
        // (e.g. UnitEnum, BackedEnum, BcMath\Number).  Parse on first
        // access and cache in the ast_map under a `phpantom-stub://` URI
        // so subsequent lookups hit Phase 1 and skip parsing entirely.
        //
        // Two lookup strategies:
        //
        //   a) **FQN lookup** — when the caller requests a namespaced
        //      name like `BcMath\Number`, look it up in the stub index
        //      by the full name.  Many PHP extensions define classes
        //      inside namespaces (Ds, BcMath, Random, Fiber, etc.).
        //
        //   b) **Short-name lookup** — when the caller requests an
        //      unqualified name like `PDO`, look it up by the short
        //      name.  This only fires when `expected_ns` is `None` to
        //      avoid `Demo\PDO` matching the global `PDO` stub.
        //
        // Strategy (a) is tried first because it is more specific.
        let stub_idx = self.stub_index.read();
        if expected_ns.is_some() {
            // Namespaced lookup — try the full FQN as a stub key.
            if let Some(&stub_content) = stub_idx.get(class_name) {
                let stub_uri = format!("phpantom-stub://{}", class_name);
                let ver = Some(self.php_version());
                if let Some(classes) =
                    self.parse_and_cache_content_versioned(stub_content, &stub_uri, ver)
                    && let Some(cls) = classes.iter().find(|c| c.name == last_segment)
                {
                    return Some(Arc::clone(cls));
                }
            }
        } else if let Some(&stub_content) = stub_idx.get(last_segment) {
            // Global-namespace lookup — match by short name only.
            let stub_uri = format!("phpantom-stub://{}", last_segment);
            let ver = Some(self.php_version());
            if let Some(classes) =
                self.parse_and_cache_content_versioned(stub_content, &stub_uri, ver)
                && let Some(cls) = classes.iter().find(|c| c.name == last_segment)
            {
                return Some(Arc::clone(cls));
            }
        }

        // Cache the negative result so subsequent lookups for the same
        // unknown class skip the expensive multi-phase search.
        self.class_not_found_cache
            .write()
            .insert(class_name.to_owned());
        None
    }

    /// Try to load a class from the embedded stub index only.
    ///
    /// This is the in-memory-only counterpart of [`find_or_load_class`].
    /// It checks the `ast_map` first (O(1) via the FQN index), and if
    /// the class hasn't been parsed yet, looks it up in the in-memory
    /// `stub_index`.  When found there, it parses and caches the stub
    /// under a `phpantom-stub://` URI so subsequent lookups are free.
    ///
    /// **No disk I/O is performed.**  Classes that live on disk (classmap,
    /// PSR-4, vendor) are not resolved — callers that need those should
    /// use [`find_or_load_class`] instead.
    pub(crate) fn load_stub_class(&self, class_name: &str) -> Option<Arc<ClassInfo>> {
        let last_segment = short_name(class_name);

        // Fast path: already parsed and cached.
        if let Some(cls) = self.find_class_in_ast_map(class_name) {
            return Some(cls);
        }

        // Look up in the in-memory stub index.
        let stub_idx = self.stub_index.read();
        let stub_content = if class_name.contains('\\') {
            // Namespaced lookup (e.g. "BcMath\\Number").
            stub_idx.get(class_name).copied()
        } else {
            // Global-namespace lookup (e.g. "PDO").
            stub_idx.get(last_segment).copied()
        };

        if let Some(content) = stub_content {
            let stub_uri = format!("phpantom-stub://{}", class_name);
            let ver = Some(self.php_version());
            if let Some(classes) = self.parse_and_cache_content_versioned(content, &stub_uri, ver)
                && let Some(cls) = classes.iter().find(|c| c.name == last_segment)
            {
                return Some(Arc::clone(cls));
            }
        }

        None
    }

    /// Parse a PHP file from disk (or from a phar archive), cache the
    /// results, and return the extracted classes.
    ///
    /// Convenience wrapper around [`parse_and_cache_content`] that reads
    /// the file and derives a URI from the path.  Used by
    /// [`find_or_load_class`] (Phases 1.5 and 2) and by the
    /// go-to-implementation scanner.
    ///
    /// **Phar support:** when `file_path` contains a `!` separator
    /// (e.g. `/path/to/phpstan.phar!src/Type/Type.php`), the left side
    /// is the phar archive path and the right side is the internal file
    /// path.  The content is extracted from the in-memory
    /// [`phar_archives`](crate::Backend::phar_archives) cache instead
    /// of reading from disk.  The URI uses a `phar://` scheme so that
    /// go-to-definition can distinguish phar-sourced classes.
    pub(crate) fn parse_and_cache_file(&self, file_path: &Path) -> Option<Vec<Arc<ClassInfo>>> {
        let path_str = file_path.to_str().unwrap_or_default();

        // ── Phar path: "archive.phar!internal/path.php" ─────────
        if let Some(sep) = path_str.find('!') {
            let phar_path = Path::new(&path_str[..sep]);
            let internal_path = &path_str[sep + 1..];

            let archives = self.phar_archives.read();
            let archive = archives.get(phar_path)?;
            let bytes = archive.read_file(internal_path)?;
            let content = std::str::from_utf8(bytes).ok()?;

            let uri = format!("phar://{}/{}", phar_path.display(), internal_path);
            return self.parse_and_cache_content(content, &uri);
        }

        // ── Regular file path ───────────────────────────────────
        let content = std::fs::read_to_string(file_path).ok()?;
        let uri = crate::util::path_to_uri(file_path);
        self.parse_and_cache_content(&content, &uri)
    }

    /// Parse PHP source text, cache the results in
    /// `ast_map`/`use_map`/`namespace_map`, and return the extracted
    /// classes.
    ///
    /// This is the single canonical implementation of the "parse → cache"
    /// pipeline.  All code paths that need to parse PHP content and store
    /// the results (file-based resolution, stub resolution, implementation
    /// scanning) funnel through here so the caching logic stays consistent.
    pub(crate) fn parse_and_cache_content(
        &self,
        content: &str,
        uri: &str,
    ) -> Option<Vec<Arc<ClassInfo>>> {
        self.parse_and_cache_content_versioned(content, uri, None)
    }

    /// Version-aware variant of [`parse_and_cache_content`].
    ///
    /// When `php_version` is `Some`, elements annotated with
    /// `#[PhpStormStubsElementAvailable]` whose version range excludes
    /// the target version are filtered out during extraction.  Used when
    /// parsing phpstorm-stubs so that only the correct variant of each
    /// function, method, or parameter is presented.
    ///
    /// # Consistency model
    ///
    /// The five maps (`ast_map`, `use_map`, `namespace_map`, `fqn_index`,
    /// `resolved_class_cache`) are written sequentially, not under a
    /// single lock.  A concurrent reader could briefly observe a state
    /// where some maps reflect the new parse while others still hold
    /// stale data for the same URI.  This is acceptable because:
    ///
    /// - All writes complete within microseconds.
    /// - Every consumer clones the data it needs from each map
    ///   independently and does not rely on cross-map atomicity.
    /// - An audit of all read sites (completion, diagnostics, hover,
    ///   definition, references, highlighting) confirmed that none
    ///   requires a consistent snapshot across multiple maps.
    ///
    /// If a future change adds a reader that checks two of these maps
    /// for consistency within the same request, the writes here must
    /// be batched under a single coordination mechanism.
    pub(crate) fn parse_and_cache_content_versioned(
        &self,
        content: &str,
        uri: &str,
        php_version: Option<PhpVersion>,
    ) -> Option<Vec<Arc<ClassInfo>>> {
        let file_use_map = self.parse_use_statements(content);
        let file_namespace = self.parse_namespace(content);

        // Parse classes with per-class namespace tracking so that
        // multi-namespace files (e.g. PDO.php with both `namespace { }`
        // and `namespace Pdo { }`) resolve parent names correctly.
        let classes_with_ns = Self::parse_php_versioned_with_namespaces(content, php_version);

        // Group classes by their enclosing namespace and resolve parent
        // names once per group, mirroring the logic in `update_ast_inner`.
        let mut classes: Vec<ClassInfo> = Vec::with_capacity(classes_with_ns.len());
        let mut ns_groups: HashMap<Option<String>, Vec<usize>> = HashMap::new();
        for (i, (_cls, ns)) in classes_with_ns.iter().enumerate() {
            ns_groups.entry(ns.clone()).or_default().push(i);
        }

        // Flatten into a single Vec, preserving original order.
        for (cls, _) in &classes_with_ns {
            classes.push(cls.clone());
        }

        if ns_groups.len() <= 1 {
            // Single namespace (common case): resolve with file namespace.
            Self::resolve_parent_class_names(&mut classes, &file_use_map, &file_namespace);
        } else {
            // Multi-namespace file: resolve each group with its own
            // namespace context so that classes in `namespace { }` are
            // not polluted by a sibling `namespace Pdo { }` block.
            for (group_ns, indices) in &ns_groups {
                let mut group: Vec<ClassInfo> =
                    indices.iter().map(|&i| classes[i].clone()).collect();
                Self::resolve_parent_class_names(&mut group, &file_use_map, group_ns);
                for (j, &idx) in indices.iter().enumerate() {
                    classes[idx] = group[j].clone();
                }
            }
        }

        // Set the per-class file_namespace so that classes loaded via
        // PSR-4 / classmap carry their namespace.  This mirrors the
        // same assignment done in `update_ast_inner` for files opened
        // through `did_open` / `did_change`.
        for (i, cls) in classes.iter_mut().enumerate() {
            if cls.file_namespace.is_none() {
                cls.file_namespace = classes_with_ns[i]
                    .1
                    .clone()
                    .or_else(|| file_namespace.clone());
            }
        }

        // Wrap each ClassInfo in Arc before inserting into the maps.
        let arc_classes: Vec<Arc<ClassInfo>> = classes.into_iter().map(Arc::new).collect();

        // Check whether this URI already has an ast_map entry before
        // we overwrite it.  Used below to decide whether resolved-class
        // cache eviction is needed (only on re-parse, not first load).
        let was_already_parsed = self.ast_map.read().contains_key(uri);

        self.ast_map
            .write()
            .insert(uri.to_owned(), arc_classes.clone());
        self.use_map.write().insert(uri.to_owned(), file_use_map);
        self.namespace_map
            .write()
            .insert(uri.to_owned(), file_namespace);

        // Populate the fqn_index so that `find_class_in_ast_map` can
        // resolve these classes via O(1) hash lookup.
        {
            let mut fqn_idx = self.fqn_index.write();
            for cls in &arc_classes {
                if cls.name.starts_with("__anonymous@") {
                    continue;
                }
                let fqn = cls.fqn();
                fqn_idx.insert(fqn, Arc::clone(cls));
            }
        }

        // Remove newly-discovered FQNs from the negative-result cache.
        {
            let nf_cache = self.class_not_found_cache.read();
            if !nf_cache.is_empty() {
                drop(nf_cache);
                let mut nf_cache = self.class_not_found_cache.write();
                for cls in &arc_classes {
                    if cls.name.starts_with("__anonymous@") {
                        continue;
                    }
                    let fqn = cls.fqn();
                    nf_cache.remove(&fqn);
                }
            }
        }

        // Selectively invalidate the resolved-class cache for the
        // classes defined in this file.
        //
        // This function is only reached when the class was NOT found
        // in ast_map (find_class_in_ast_map / fqn_index returned None).
        // That means the class has never been parsed — so it cannot
        // have a direct entry in the resolved-class cache.
        //
        // Dependents (e.g. a child class resolved before this parent
        // was available) *could* hold stale entries, but the transitive
        // evict_fqn scan is O(cache_size) per class and is called for
        // every vendor class loaded from classmap / PSR-4 / stubs.
        // With thousands of classes this becomes O(N²) and dominates
        // total analysis time.
        //
        // Instead, only evict when the URI was already present in
        // ast_map (i.e. a re-parse of a previously loaded file, which
        // can happen in the LSP editing path).  For first-time loads
        // the cost/benefit is strongly negative.
        if was_already_parsed {
            let mut cache = self.resolved_class_cache.lock();
            for cls in &arc_classes {
                let fqn = cls.fqn();
                crate::virtual_members::evict_fqn(&mut cache, &fqn);
            }
        }

        Some(arc_classes)
    }

    /// Try to find a standalone function by name, checking user-defined
    /// functions first, then falling back to embedded PHP stubs.
    ///
    /// The lookup order is:
    ///   1. `global_functions` — functions from Composer autoload files and
    ///      opened/changed files.
    ///   2. `stub_function_index` — built-in PHP functions embedded from
    ///      phpstorm-stubs.  Parsed lazily on first access and cached in
    ///      `global_functions` under a `phpantom-stub-fn://` URI so
    ///      subsequent lookups hit step 1.
    ///
    /// `candidates` is a list of names to try (e.g. the bare name, the
    /// FQN via use-map, the namespace-qualified name).  The first match
    /// wins.
    pub fn find_or_load_function(&self, candidates: &[&str]) -> Option<FunctionInfo> {
        // ── Phase 1: Check global_functions (user code + already-cached stubs) ──
        {
            let fmap = self.global_functions.read();
            for &name in candidates {
                if let Some((_, info)) = fmap.get(name) {
                    return Some(info.clone());
                }
            }
        }

        // ── Phase 1.5: Check autoload_function_index (byte-level scan) ──
        // The lightweight `find_symbols` byte-level scan discovers
        // function names at startup without a full AST parse, for both
        // non-Composer projects (workspace scan) and Composer projects
        // (autoload_files.php scan).  When a candidate matches here, we
        // lazily call `update_ast` on the file to get a complete
        // `FunctionInfo` and cache it in global_functions so subsequent
        // lookups hit Phase 1.
        //
        // Note: the lazy parse is a full AST parse (`update_ast`), which
        // is the same cost as opening the file.  This is acceptable
        // because it only happens once per function, on first access.
        {
            let idx = self.autoload_function_index.read();
            for &name in candidates {
                if let Some(path) = idx.get(name) {
                    let path = path.clone();
                    drop(idx); // release read lock before parsing

                    if let Ok(content) = std::fs::read_to_string(&path) {
                        let uri = crate::util::path_to_uri(&path);
                        self.update_ast(&uri, &content);

                        // Re-check global_functions after parsing.
                        let fmap = self.global_functions.read();
                        for &retry_name in candidates {
                            if let Some((_, info)) = fmap.get(retry_name) {
                                return Some(info.clone());
                            }
                        }
                    }
                    break; // Only try one file per lookup
                }
            }
        }

        // ── Phase 1.75: Last-resort lazy parse of known autoload files ──
        // The byte-level scanner misses functions wrapped in
        // `if (! function_exists(...))` guards (brace depth > 0).
        // These are common in Laravel helpers and similar packages.
        // As a safety net, lazily parse each known autoload file via
        // `update_ast` until the function is found.  Each file is
        // parsed at most once: subsequent lookups hit Phase 1
        // (`global_functions`).
        {
            let paths = self.autoload_file_paths.read().clone();
            for path in &paths {
                // Skip files that have already been fully parsed (their
                // functions are already in global_functions via Phase 1).
                let uri = crate::util::path_to_uri(path);
                if self.ast_map.read().contains_key(&uri) {
                    continue;
                }

                if let Ok(content) = std::fs::read_to_string(path) {
                    self.update_ast(&uri, &content);

                    let fmap = self.global_functions.read();
                    for &name in candidates {
                        if let Some((_, info)) = fmap.get(name) {
                            return Some(info.clone());
                        }
                    }
                }
            }
        }

        // ── Phase 2: Try embedded PHP stubs ──
        // The stub_function_index maps function names (including namespaced
        // ones like "Brotli\\compress") to the raw PHP source of the file
        // that defines them.  We parse the entire file, cache all discovered
        // functions in global_functions, and return the one we need.
        let stub_fn_idx = self.stub_function_index.read();
        for &name in candidates {
            if let Some(&stub_content) = stub_fn_idx.get(name) {
                let ver = Some(self.php_version());
                let functions = self.parse_functions_versioned(stub_content, ver);

                if functions.is_empty() {
                    continue;
                }

                let stub_uri = format!("phpantom-stub-fn://{}", name);
                let mut result: Option<FunctionInfo> = None;

                {
                    let mut fmap = self.global_functions.write();
                    for func in &functions {
                        let fqn = if let Some(ref ns) = func.namespace {
                            format!("{}\\{}", ns, &func.name)
                        } else {
                            func.name.clone()
                        };

                        // Check if this is the function we're looking for.
                        if result.is_none() && (fqn == name || func.name == name) {
                            result = Some(func.clone());
                        }

                        // Cache the FQN so future lookups hit Phase 1.
                        // No short-name fallback: `resolve_function_name`
                        // already builds namespace-qualified candidates.
                        fmap.entry(fqn)
                            .or_insert_with(|| (stub_uri.clone(), func.clone()));
                    }
                }

                // Also cache any classes defined in the same stub file so
                // that class lookups for types referenced by the function
                // (e.g. return types) can find them later.
                let mut classes = Self::parse_php_versioned(stub_content, ver);
                if !classes.is_empty() {
                    let empty_use_map = HashMap::new();
                    let stub_namespace = self.parse_namespace(stub_content);
                    Self::resolve_parent_class_names(&mut classes, &empty_use_map, &stub_namespace);
                    let class_uri = format!("phpantom-stub-fn://{}", name);
                    let arc_classes: Vec<Arc<ClassInfo>> =
                        classes.into_iter().map(Arc::new).collect();
                    self.ast_map.write().insert(class_uri, arc_classes);
                }

                if result.is_some() {
                    return result;
                }
            }
        }

        None
    }

    // ─── Shared Name Resolution ─────────────────────────────────────────────

    /// Resolve a class name using use-map, namespace, local classes, and
    /// cross-file / PSR-4 / stubs.
    ///
    /// This is the single canonical implementation of the "class_loader"
    /// logic used by the completion handler, definition resolver, and
    /// named-argument resolution.  It handles:
    ///
    ///   - Fully-qualified names (`\PDO`, `\Couchbase\Cluster`)
    ///   - Unqualified names resolved via the import table (`use` statements),
    ///     local class list, current namespace, or global scope
    ///   - Qualified names with alias expansion and namespace prefixing
    pub(crate) fn resolve_class_name(
        &self,
        name: &str,
        local_classes: &[Arc<ClassInfo>],
        file_use_map: &HashMap<String, String>,
        file_namespace: &Option<String>,
    ) -> Option<Arc<ClassInfo>> {
        // ── Fully qualified name (leading `\`) ──────────────
        if let Some(stripped) = name.strip_prefix('\\') {
            return self.find_or_load_class(stripped);
        }

        // ── Unqualified name (no `\` at all) ────────────────
        if !name.contains('\\') {
            // Check the import table first (`use` statements).
            if let Some(fqn) = file_use_map.get(name) {
                return self.find_or_load_class(fqn);
            }
            // Check local classes (same-file shortcut).
            let lookup = short_name(name);
            if let Some(cls) = local_classes.iter().find(|c| c.name == lookup) {
                return Some(Arc::clone(cls));
            }
            // In a namespace, try the namespace-qualified form first.
            // Per PHP semantics, class names do NOT fall back to global
            // scope (unlike functions/constants).  However, names that
            // arrive here may be already-resolved FQNs from ClassInfo
            // fields (e.g. `parent_class = "Exception"`) that happen to
            // be single-segment global names.  For those, the namespace-
            // qualified attempt will fail, so we fall back to a direct
            // lookup.  To preserve PHP semantics for user-typed code,
            // the namespace-qualified form is tried first and wins when
            // a same-named class exists in the current namespace.
            if let Some(ns) = file_namespace {
                let ns_qualified = format!("{}\\{}", ns, name);
                if let Some(cls) = self.find_or_load_class(&ns_qualified) {
                    return Some(cls);
                }
            }
            // Global scope: either no namespace context, or the
            // namespace-qualified lookup above did not find a match.
            return self.find_or_load_class(name);
        }

        // ── Qualified name (contains `\`, no leading `\`) ───
        // Check if the first segment is a use-map alias
        // (e.g. `OA\Endpoint` where `use Swagger\OpenAPI as OA;`
        // maps `OA` → `Swagger\OpenAPI`).  Expand to FQN.
        let first_segment = name.split('\\').next().unwrap_or(name);
        if let Some(fqn_prefix) = file_use_map.get(first_segment) {
            let rest = &name[first_segment.len()..];
            let expanded = format!("{}{}", fqn_prefix, rest);
            if let Some(cls) = self.find_or_load_class(&expanded) {
                return Some(cls);
            }
        }
        // Prepend current namespace (if any).
        if let Some(ns) = file_namespace {
            let ns_qualified = format!("{}\\{}", ns, name);
            if let Some(cls) = self.find_or_load_class(&ns_qualified) {
                return Some(cls);
            }
        }
        // Fall back to the name as-is.  Qualified names that
        // reach here are typically already-resolved FQNs from
        // the parser (parent classes, traits, mixins) that
        // were resolved by `resolve_parent_class_names` before
        // being stored.
        self.find_or_load_class(name)
    }

    /// Resolve a function name using use-map and namespace context.
    ///
    /// Builds a list of candidate names (exact name, use-map resolved,
    /// namespace-qualified) and tries each via `find_or_load_function`.
    ///
    /// This is the single canonical implementation of the "function_loader"
    /// logic used by both the completion handler and definition resolver.
    pub(crate) fn resolve_function_name(
        &self,
        name: &str,
        file_use_map: &HashMap<String, String>,
        file_namespace: &Option<String>,
    ) -> Option<FunctionInfo> {
        // Build candidate names to try: exact name, use-map
        // resolved name, and namespace-qualified name.
        let mut candidates: Vec<&str> = vec![name];

        let use_resolved: Option<String> = file_use_map.get(name).cloned();
        if let Some(ref fqn) = use_resolved {
            candidates.push(fqn.as_str());
        }

        let ns_qualified: Option<String> = file_namespace
            .as_ref()
            .map(|ns| format!("{}\\{}", ns, name));
        if let Some(ref nq) = ns_qualified {
            candidates.push(nq.as_str());
        }

        // Unified lookup: checks global_functions first, then
        // falls back to embedded PHP stubs (parsed lazily and
        // cached for future lookups).
        self.find_or_load_function(&candidates)
    }

    // ─── Loader Closure Factories ───────────────────────────────────────

    /// Return a class-loader closure bound to a [`FileContext`].
    ///
    /// This is the convenience wrapper for the common case where the
    /// caller already has a `FileContext`.  For situations that need a
    /// different class list (e.g. patched/effective classes after error
    /// recovery), use [`class_loader_with`](Self::class_loader_with).
    pub(crate) fn class_loader<'a>(
        &'a self,
        ctx: &'a FileContext,
    ) -> impl Fn(&str) -> Option<Arc<ClassInfo>> + 'a {
        self.class_loader_with(&ctx.classes, &ctx.use_map, &ctx.namespace)
    }

    /// Return a class-loader closure from individual file-context
    /// components.
    ///
    /// Useful when the class list differs from what is stored in a
    /// `FileContext` (e.g. after re-parsing patched content for error
    /// recovery).
    pub(crate) fn class_loader_with<'a>(
        &'a self,
        classes: &'a [Arc<ClassInfo>],
        use_map: &'a HashMap<String, String>,
        namespace: &'a Option<String>,
    ) -> impl Fn(&str) -> Option<Arc<ClassInfo>> + 'a {
        move |name: &str| self.resolve_class_name(name, classes, use_map, namespace)
    }

    /// Return a function-loader closure bound to a [`FileContext`].
    ///
    /// This is the convenience wrapper for the common case where the
    /// caller already has a `FileContext`.  For situations that need
    /// explicit use-map / namespace values, use
    /// [`function_loader_with`](Self::function_loader_with).
    pub(crate) fn function_loader<'a>(
        &'a self,
        ctx: &'a FileContext,
    ) -> impl Fn(&str) -> Option<FunctionInfo> + 'a {
        self.function_loader_with(&ctx.use_map, &ctx.namespace)
    }

    /// Return a function-loader closure from individual file-context
    /// components.
    ///
    /// Useful when the caller does not have a full `FileContext` or
    /// needs to use a different use-map / namespace.
    pub(crate) fn function_loader_with<'a>(
        &'a self,
        use_map: &'a HashMap<String, String>,
        namespace: &'a Option<String>,
    ) -> impl Fn(&str) -> Option<FunctionInfo> + 'a {
        move |name: &str| self.resolve_function_name(name, use_map, namespace)
    }

    /// Return a constant-value-loader closure.
    ///
    /// The returned closure looks up a global constant name and returns
    /// `Some(Some(value))` when found with a known value,
    /// `Some(None)` when found without a value, and `None` when not
    /// found.
    pub(crate) fn constant_loader(&self) -> impl Fn(&str) -> Option<Option<String>> + '_ {
        move |name: &str| self.lookup_global_constant(name)
    }
}