Skip to main content

projectatlas_symbols/
lib.rs

1//! Purpose: Extract tree-sitter-backed `ProjectAtlas` symbol graphs.
2
3mod configured_modules;
4/// Canonical source declaration scopes shared by parser proof and graph publication.
5pub mod declaration_identity;
6mod documents;
7mod import_source;
8mod languages;
9mod lexical_bindings;
10mod markdown;
11mod resolution_keys;
12mod rust_binding_source;
13mod rust_use_source;
14mod semantic;
15mod source_positions;
16mod source_semantics;
17
18pub use rust_binding_source::{
19    SourceCargoAutomaticTarget, SourceCargoTarget, SourceCargoTargetKind, SourceCargoTargets,
20    SourceRustAnchor, SourceRustAssociated, SourceRustBinding, SourceRustCall,
21    SourceRustCallImports, SourceRustCallOrigin, SourceRustDeclaration, SourceRustFacts,
22    SourceRustModule, SourceRustPath, SourceRustUse, SourceRustUseRole, SourceRustVisibility,
23    cargo_automatic_target_directory, cargo_root_cohort_key, cargo_target_inventory_key,
24};
25pub use source_semantics::{
26    SourceByteSpan, SourceCallBinding, SourceCallFact, SourceExportBinding, SourceExportFact,
27    SourceExportNamespace, SourceExtraction, SourceImportFact, SourceSemanticFacts,
28    SourceSemanticRemapError,
29};
30
31pub use configured_modules::{
32    ConfiguredModuleError, ConfiguredModuleResolution, EcmaScriptConfigKind,
33    EcmaScriptModuleConfig, EcmaScriptPathMapping, MAX_CONFIGURED_MODULE_CONFIGS,
34    MAX_CONFIGURED_MODULE_IDENTITY_BYTES, MAX_CONFIGURED_MODULE_MAPPINGS,
35    MAX_CONFIGURED_MODULE_TARGETS,
36};
37pub use documents::{
38    DOCUMENT_COVERAGE_SYMBOL, DOCX_DOCUMENT_PART, DocumentCompleteness, DocumentCoverageGap,
39    DocumentExtractionError, DocumentFact, DocumentFacts, DocumentFormat, DocumentLimit,
40    DocumentLocator, DocumentParserProvenance, DocumentSymbol, LOPDF_VERSION,
41    MAX_DOCUMENT_COMPRESSED_BYTES, MAX_DOCUMENT_ENTRIES, MAX_DOCUMENT_EXPANDED_BYTES,
42    MAX_DOCUMENT_FACTS, MAX_DOCUMENT_MEMORY_BYTES, MAX_DOCUMENT_OUTPUT_BYTES,
43    MAX_DOCUMENT_RECURSION_DEPTH, PDF_EXTRACT_VERSION, QUICK_XML_VERSION, document_format_for_path,
44    extract_document_graph_controlled, extract_document_symbol_facts_controlled,
45    extract_document_text_controlled,
46};
47pub use markdown::{
48    DocumentLinkCandidate, DocumentLinkSource, MAX_DOCUMENT_LINK_CANDIDATES,
49    MAX_DOCUMENT_SELECTOR_BYTES, MAX_MARKDOWN_BYTES, MAX_MARKDOWN_EVIDENCE_BYTES,
50    MAX_MARKDOWN_HEADINGS, MAX_MARKDOWN_LABEL_BYTES, MarkdownFactCompleteness,
51    MarkdownFactCoverage, MarkdownFactLimit, MarkdownFacts, MarkdownHeadingFact,
52    MarkdownParserProvenance, MarkdownSourceSelector, MarkdownUnsupportedStructure,
53    extract_markdown_facts, extract_markdown_facts_controlled,
54};
55pub use resolution_keys::{
56    ExportResolutionKeys, ImportReference, ImportSyntax, MAX_RESOLUTION_KEYS_PER_FACT,
57    MAX_RESOLUTION_PROJECTION_FAILURES, RelationResolutionKeys, ResolutionKeyProjection,
58    ResolutionProjectionContext, ResolutionProjectionError, ResolutionProjectionFact,
59    ResolutionProjectionFactFailure, ResolutionProjectionFailure,
60    SEMANTIC_RESOLUTION_CONTRACT_VERSION, SymbolResolutionKeys, derive_resolution_keys,
61    derive_resolution_keys_with_context, module_aliases_for_path, parse_import_references,
62    resolve_relative_import_path, semantic_resolution_contract_digest, source_stems_for_path,
63};
64
65use source_semantics::SourceSymbolRemap;
66
67use projectatlas_core::graph::{GraphIdentityText, QUALIFIED_SYMBOL_SCOPE_PREFIX};
68use projectatlas_core::language::{
69    EmbeddedHostKind, EmbeddedLanguageCapability, SemanticProviderOwner, SymbolParserOwner,
70    TreeSitterGrammar, builtin_tree_sitter_language_ids, language_capability, tree_sitter_grammar,
71};
72use projectatlas_core::symbols::{
73    CodeSymbol, MODULE_RELATION_SOURCE, ParserKind, RelationKind, SymbolGraph, SymbolKind,
74    SymbolRelation, SymbolSourceSelector,
75};
76use projectatlas_core::{IndexWorkControl, IndexWorkFailure, IndexWorkStage};
77use regex::Regex;
78use std::borrow::Cow;
79use std::collections::{BTreeMap, BTreeSet};
80use std::convert::Infallible;
81use std::ops::ControlFlow;
82use std::path::Path;
83use toml::Value as TomlValue;
84use tree_sitter::{Language, Node, ParseOptions, Parser, Tree};
85
86/// Maximum symbols kept from one file to bound large generated sources.
87const MAX_SYMBOLS_PER_FILE: usize = 4_000;
88/// Maximum relations kept from one file to bound call-heavy sources.
89const MAX_RELATIONS_PER_FILE: usize = 8_000;
90/// Bound native traversal stacks before lexical and language-specific refinement.
91const MAX_TREE_SITTER_AST_DEPTH: usize = 256;
92/// Maximum text length stored for symbol names, signatures, and relation context.
93const MAX_SNIPPET_CHARS: usize = 240;
94/// Maximum text length stored for extracted documentation.
95const MAX_DOC_CHARS: usize = 500;
96/// Maximum parsed rows between cooperative cancellation/deadline checks.
97const PARSER_CONTROL_CHECK_INTERVAL: usize = 128;
98
99/// Extract a symbol graph from source or manifest content.
100#[must_use]
101pub fn extract_symbol_graph(path: &str, language: Option<&str>, content: &str) -> SymbolGraph {
102    match extract_symbol_graph_checked(path, language, content, &mut || Ok::<(), Infallible>(())) {
103        Ok(graph) => graph,
104        Err(unreachable) => match unreachable {},
105    }
106}
107
108/// Extract a symbol graph while observing the shared indexing cancellation boundary.
109///
110/// # Errors
111///
112/// Returns a typed cancellation or deadline failure without returning a partial graph.
113pub fn extract_symbol_graph_controlled(
114    path: &str,
115    language: Option<&str>,
116    content: &str,
117    control: &IndexWorkControl,
118) -> Result<SymbolGraph, IndexWorkFailure> {
119    extract_symbol_graph_checked(path, language, content, &mut || {
120        control.check(IndexWorkStage::SymbolParsing)
121    })
122}
123
124/// Extract a graph and the parser that produced its retained source facts.
125///
126/// The first tuple item preserves source provenance independently of conservative
127/// fact confidence. Cancellation never returns a partial extraction result.
128///
129/// # Errors
130///
131/// Returns a typed cancellation or deadline failure.
132pub fn extract_symbol_graph_with_source_controlled(
133    path: &str,
134    language: Option<&str>,
135    content: &str,
136    control: &IndexWorkControl,
137) -> Result<(ParserKind, SymbolGraph), IndexWorkFailure> {
138    extract_symbol_graph_with_source_checked(path, language, content, &mut || {
139        control.check(IndexWorkStage::SymbolParsing)
140    })
141}
142
143/// Extract graph facts with source-bound import and call occurrence evidence.
144///
145/// Binding proof is independent from display names and graph provenance. Missing
146/// or unresolved source evidence never authorizes an exact target. Consumers
147/// filtering facts must remap evidence before using its ordinal associations.
148///
149/// # Errors
150///
151/// Returns a typed cancellation or deadline failure without partial evidence.
152pub fn extract_symbol_graph_with_semantics_controlled(
153    path: &str,
154    language: Option<&str>,
155    content: &str,
156    control: &IndexWorkControl,
157) -> Result<SourceExtraction, IndexWorkFailure> {
158    let mut semantics = SourceSemanticFacts::default();
159    let (source_parser, graph) = extract_symbol_graph_with_semantics_checked(
160        path,
161        language,
162        content,
163        &mut || control.check(IndexWorkStage::SymbolParsing),
164        Some(&mut semantics),
165    )?;
166    semantics.bind_source(&graph, content);
167    semantics.bind_source_spans(content, &mut || {
168        control.check(IndexWorkStage::SymbolParsing)
169    })?;
170    control.check(IndexWorkStage::SymbolParsing)?;
171    Ok(SourceExtraction {
172        source_parser,
173        graph,
174        semantics,
175    })
176}
177
178/// Preserve graph-only extraction for callers that do not consume source provenance.
179fn extract_symbol_graph_checked<E>(
180    path: &str,
181    language: Option<&str>,
182    content: &str,
183    check: &mut impl FnMut() -> Result<(), E>,
184) -> Result<SymbolGraph, E> {
185    extract_symbol_graph_with_source_checked(path, language, content, check).map(|(_, graph)| graph)
186}
187
188/// Extract a symbol graph with one cooperative work checkpoint shared by every parser stage.
189fn extract_symbol_graph_with_source_checked<E>(
190    path: &str,
191    language: Option<&str>,
192    content: &str,
193    check: &mut impl FnMut() -> Result<(), E>,
194) -> Result<(ParserKind, SymbolGraph), E> {
195    extract_symbol_graph_with_semantics_checked(path, language, content, check, None)
196}
197
198/// Share the existing parse while optionally retaining typed occurrence evidence.
199fn extract_symbol_graph_with_semantics_checked<E>(
200    path: &str,
201    language: Option<&str>,
202    content: &str,
203    check: &mut impl FnMut() -> Result<(), E>,
204    semantics: Option<&mut SourceSemanticFacts>,
205) -> Result<(ParserKind, SymbolGraph), E> {
206    check()?;
207    let parse_content = content_without_leading_purpose_header(content);
208    if let Some(capability) = semantic::embedded_source::host_capability(path, language) {
209        return extract_embedded_host_graph_checked(
210            path,
211            language,
212            parse_content.as_ref(),
213            capability,
214            check,
215            semantics,
216        )
217        .map(|graph| (graph.parser, graph));
218    }
219    match symbol_parser_owner(path, language) {
220        SymbolParserOwner::CargoManifest => {
221            return extract_cargo_manifest_graph_checked(path, language, content, check, semantics)
222                .map(|graph| (graph.parser, graph));
223        }
224        SymbolParserOwner::Vue => {
225            return extract_vue_sfc_graph_checked(path, language, parse_content.as_ref(), check)
226                .map(|graph| (graph.parser, graph));
227        }
228        SymbolParserOwner::PowerShell => {
229            return extract_powershell_graph_checked(path, language, parse_content.as_ref(), check)
230                .map(|graph| (graph.parser, graph));
231        }
232        SymbolParserOwner::Markdown => {
233            let facts = markdown::extract_markdown_facts_checked(parse_content.as_ref(), check)?;
234            let graph = facts.symbol_graph(path, language);
235            return Ok((graph.parser, graph));
236        }
237        SymbolParserOwner::Document => {
238            // Binary documents enter through the bytes-aware adapter in the
239            // CLI runtime; a text-only caller cannot safely reinterpret them.
240            check()?;
241            return Ok((
242                ParserKind::Structural,
243                empty_graph(path, language, ParserKind::Structural),
244            ));
245        }
246        SymbolParserOwner::Unavailable => {
247            check()?;
248            return Ok((
249                ParserKind::Structural,
250                empty_graph(path, language, ParserKind::Structural),
251            ));
252        }
253        SymbolParserOwner::TreeSitter(_) | SymbolParserOwner::Fallback => {}
254    }
255    if let Some(mut parsed) = extract_tree_sitter_graph(
256        path,
257        language,
258        parse_content.as_ref(),
259        check,
260        semantics,
261        None,
262    )? {
263        normalize_source_selector_columns(&mut parsed.graph, content, check)?;
264        if parsed.incomplete || parsed.graph.parser == ParserKind::Fallback {
265            mark_graph_fallback(&mut parsed.graph);
266        }
267        // PHP partial facts retain their grammar origin; a rescue below owns
268        // fallback provenance independently of the file's language capability.
269        let source_parser =
270            if language.and_then(tree_sitter_grammar) == Some(TreeSitterGrammar::Php) {
271                ParserKind::TreeSitter
272            } else {
273                parsed.graph.parser
274            };
275        if !parsed.graph.symbols.is_empty() || !parsed.graph.relations.is_empty() {
276            check()?;
277            return Ok((source_parser, parsed.graph));
278        }
279        if parsed.had_errors {
280            let fallback =
281                extract_fallback_graph_checked(path, language, parse_content.as_ref(), check)?;
282            if !fallback.symbols.is_empty() || !fallback.relations.is_empty() {
283                check()?;
284                return Ok((fallback.parser, fallback));
285            }
286        }
287        check()?;
288        return Ok((source_parser, parsed.graph));
289    }
290    extract_fallback_graph_checked(path, language, parse_content.as_ref(), check)
291        .map(|graph| (graph.parser, graph))
292}
293
294/// Extract accepted inline script facts without changing their host-file positions.
295fn extract_embedded_host_graph_checked<E>(
296    path: &str,
297    language: Option<&str>,
298    content: &str,
299    capability: EmbeddedLanguageCapability,
300    check: &mut impl FnMut() -> Result<(), E>,
301    mut semantics: Option<&mut SourceSemanticFacts>,
302) -> Result<SymbolGraph, E> {
303    let mut graph = match capability.host_kind {
304        EmbeddedHostKind::HtmlLike => empty_graph(path, language, ParserKind::Structural),
305        EmbeddedHostKind::Component => {
306            extract_vue_sfc_graph_checked(path, language, content, check)?
307        }
308        EmbeddedHostKind::Template => {
309            extract_fallback_graph_checked(path, language, content, check)?
310        }
311    };
312    let (projections, incomplete) =
313        semantic::embedded_source::project_for_host(content, capability.host_kind).into_parts();
314    // Embedded hosts retain their structural/fallback graph parser. Runtime
315    // coverage therefore remains partial even when admitted tree-sitter facts
316    // are merged, including when reconciliation stopped after a safe prefix.
317    for projection in projections {
318        check()?;
319        let mut source_semantics = SourceSemanticFacts::default();
320        if let Some(parsed) = extract_tree_sitter_graph(
321            path,
322            Some(projection.language().as_str()),
323            projection.source(),
324            check,
325            Some(&mut source_semantics),
326            Some(projection.regions()),
327        )? {
328            if semantics.is_some() {
329                source_semantics.bind_source(&parsed.graph, projection.source());
330            }
331            let mut ordinals = semantics.as_ref().map(|_| GraphMergeOrdinals {
332                symbols: vec![SourceSymbolRemap::Omitted; parsed.graph.symbols.len()],
333                relations: vec![None; parsed.graph.relations.len()],
334            });
335            let compatibility_imports =
336                paired_host_import_rows(&graph, &parsed.graph, &source_semantics, content, check)?;
337            merge_missing_graph_entries_checked(
338                &mut graph,
339                parsed.graph,
340                &compatibility_imports,
341                capability.host_kind,
342                check,
343                ordinals.as_mut(),
344            )?;
345            if let Some(destination) = semantics.as_deref_mut()
346                && let Some(ordinals) = ordinals
347                && destination
348                    .append_mapped(
349                        source_semantics,
350                        &graph,
351                        &ordinals.symbols,
352                        &ordinals.relations,
353                    )
354                    .is_err()
355            {
356                // A correspondence failure cannot authorize partial binding guesses.
357                *destination = SourceSemanticFacts::default();
358                mark_graph_fallback(&mut graph);
359            }
360        }
361    }
362    if incomplete.is_some()
363        && let Some(facts) = semantics
364    {
365        // Unknown/duplicate host roles or omitted regions cannot prove the
366        // complete lexical environment. Keep raw evidence and static imports.
367        facts.bindings_incomplete = true;
368        facts.exports_incomplete = true;
369        facts.exports.clear();
370        for call in &mut facts.calls {
371            check()?;
372            call.binding = SourceCallBinding::Unresolved;
373        }
374    }
375    check()?;
376    Ok(graph)
377}
378
379/// Check cooperative parser control at a bounded row interval.
380pub(crate) fn check_parser_iteration<E>(
381    iteration: usize,
382    check: &mut impl FnMut() -> Result<(), E>,
383) -> Result<(), E> {
384    if iteration.is_multiple_of(PARSER_CONTROL_CHECK_INTERVAL) {
385        check()?;
386    }
387    Ok(())
388}
389
390/// Return whether the language has a specialized tree-sitter parser.
391#[must_use]
392pub fn has_specialized_parser(language: &str) -> bool {
393    tree_sitter_grammar(language).is_some()
394}
395
396/// Return all specialized parser language identifiers.
397#[must_use]
398pub fn specialized_languages() -> &'static [&'static str] {
399    builtin_tree_sitter_language_ids()
400}
401
402/// Select the accepted parser owner, falling back to legacy path inference only without a language.
403fn symbol_parser_owner(path: &str, language: Option<&str>) -> SymbolParserOwner {
404    if let Some(language) = language {
405        return language_capability(language).map_or(SymbolParserOwner::Fallback, |capability| {
406            capability.symbol_parser
407        });
408    }
409    let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
410    if matches!(file_name, "Cargo.toml" | "Cargo.lock") {
411        return SymbolParserOwner::CargoManifest;
412    }
413    match Path::new(path)
414        .extension()
415        .and_then(|extension| extension.to_str())
416    {
417        Some(extension) if extension.eq_ignore_ascii_case("vue") => SymbolParserOwner::Vue,
418        Some(extension)
419            if ["ps1", "psm1", "psd1"]
420                .iter()
421                .any(|expected| extension.eq_ignore_ascii_case(expected)) =>
422        {
423            SymbolParserOwner::PowerShell
424        }
425        _ => SymbolParserOwner::Fallback,
426    }
427}
428
429/// Extract Vue SFC Composition API bindings with cooperative parser control.
430fn extract_vue_sfc_graph_checked<E>(
431    path: &str,
432    language: Option<&str>,
433    content: &str,
434    check: &mut impl FnMut() -> Result<(), E>,
435) -> Result<SymbolGraph, E> {
436    let mut graph = extract_fallback_graph_checked(path, language, content, check)?;
437    graph.parser = ParserKind::Structural;
438    let mut structural = empty_graph(path, language, ParserKind::Structural);
439    for (line_index, line) in content.lines().enumerate() {
440        check_parser_iteration(line_index, check)?;
441        let trimmed = line.trim();
442        if let Some(name) = vue_composition_binding_name(trimmed) {
443            push_symbol(
444                &mut structural,
445                &name,
446                SymbolKind::Value,
447                line_index + 1,
448                line_index + 1,
449                None,
450                Some("vue-composition-binding"),
451                trimmed,
452            );
453        }
454        if is_fallback_import(trimmed) {
455            push_relation(
456                &mut structural,
457                MODULE_RELATION_SOURCE,
458                trimmed,
459                RelationKind::Imports,
460                line_index + 1,
461                trimmed,
462            );
463        }
464    }
465    merge_preferred_graph_entries_checked(&mut graph, structural, check)?;
466    check()?;
467    Ok(graph)
468}
469
470/// Extract `PowerShell` declarations with cooperative parser control.
471fn extract_powershell_graph_checked<E>(
472    path: &str,
473    language: Option<&str>,
474    content: &str,
475    check: &mut impl FnMut() -> Result<(), E>,
476) -> Result<SymbolGraph, E> {
477    let mut graph = extract_fallback_graph_checked(path, language, content, check)?;
478    graph.parser = ParserKind::Structural;
479    let mut structural = empty_graph(path, language, ParserKind::Structural);
480    for (line_index, line) in content.lines().enumerate() {
481        check_parser_iteration(line_index, check)?;
482        let trimmed = line.trim();
483        if let Some(name) = powershell_function_name(trimmed) {
484            push_symbol(
485                &mut structural,
486                &name,
487                SymbolKind::Function,
488                line_index + 1,
489                line_index + 1,
490                None,
491                Some("powershell-function"),
492                trimmed,
493            );
494        }
495        if let Some(name) = powershell_class_name(trimmed) {
496            push_symbol(
497                &mut structural,
498                &name,
499                SymbolKind::Class,
500                line_index + 1,
501                line_index + 1,
502                None,
503                Some("powershell-class"),
504                trimmed,
505            );
506        }
507        if is_fallback_import(trimmed) {
508            push_relation(
509                &mut structural,
510                MODULE_RELATION_SOURCE,
511                trimmed,
512                RelationKind::Imports,
513                line_index + 1,
514                trimmed,
515            );
516        }
517    }
518    merge_preferred_graph_entries_checked(&mut graph, structural, check)?;
519    check()?;
520    Ok(graph)
521}
522
523/// Extract one `PowerShell` function declaration name.
524fn powershell_function_name(line: &str) -> Option<String> {
525    let mut parts = line.split_whitespace();
526    if !parts.next()?.eq_ignore_ascii_case("function") {
527        return None;
528    }
529    let raw_name = parts.next()?;
530    let name = raw_name.split(['(', '{']).next().unwrap_or_default().trim();
531    let name = name.rsplit_once(':').map_or(name, |(_, scoped)| scoped);
532    let valid = !name.is_empty()
533        && name
534            .chars()
535            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'));
536    valid.then(|| name.to_string())
537}
538
539/// Extract one `PowerShell` class declaration name.
540fn powershell_class_name(line: &str) -> Option<String> {
541    let mut parts = line.split_whitespace();
542    if !parts.next()?.eq_ignore_ascii_case("class") {
543        return None;
544    }
545    let raw_name = parts.next()?;
546    let name = raw_name
547        .split([':', '{', '('])
548        .next()
549        .unwrap_or_default()
550        .trim();
551    let valid = !name.is_empty()
552        && name
553            .chars()
554            .all(|character| character.is_ascii_alphanumeric() || character == '_');
555    valid.then(|| name.to_string())
556}
557
558/// Merge preferred graph entries with cooperative parser control.
559fn merge_preferred_graph_entries_checked<E>(
560    graph: &mut SymbolGraph,
561    preferred: SymbolGraph,
562    check: &mut impl FnMut() -> Result<(), E>,
563) -> Result<(), E> {
564    for (iteration, symbol) in preferred.symbols.into_iter().enumerate() {
565        check_parser_iteration(iteration, check)?;
566        if let Some(existing) = graph
567            .symbols
568            .iter()
569            .position(|existing| same_symbol_identity(existing, &symbol))
570        {
571            graph.symbols[existing] = symbol;
572        } else if graph.symbols.len() < MAX_SYMBOLS_PER_FILE {
573            graph.symbols.push(symbol);
574        }
575    }
576    for (iteration, relation) in preferred.relations.into_iter().enumerate() {
577        check_parser_iteration(iteration, check)?;
578        if let Some(existing) = graph
579            .relations
580            .iter()
581            .position(|existing| same_relation_identity(existing, &relation))
582        {
583            graph.relations[existing] = relation;
584        } else if graph.relations.len() < MAX_RELATIONS_PER_FILE {
585            graph.relations.push(relation);
586        }
587    }
588    Ok(())
589}
590
591/// Surviving parser ordinals through an append-only host merge.
592struct GraphMergeOrdinals {
593    /// Native declaration indices mapped to unique retained host declarations.
594    symbols: Vec<SourceSymbolRemap>,
595    /// Native relation occurrences mapped independently into host rows.
596    relations: Vec<Option<usize>>,
597}
598
599/// Pair whole-line rescue imports only with the actual first AST import on that line.
600fn paired_host_import_rows<E>(
601    graph: &SymbolGraph,
602    native: &SymbolGraph,
603    facts: &SourceSemanticFacts,
604    content: &str,
605    check: &mut impl FnMut() -> Result<(), E>,
606) -> Result<BTreeMap<usize, usize>, E> {
607    let mut rescued = BTreeMap::<usize, Vec<usize>>::new();
608    for (index, relation) in graph.relations.iter().enumerate() {
609        check_parser_iteration(index, check)?;
610        if relation.kind == RelationKind::Imports
611            && relation.parser != ParserKind::TreeSitter
612            && relation.source_name == MODULE_RELATION_SOURCE
613        {
614            rescued.entry(relation.line).or_default().push(index);
615        }
616    }
617    if rescued.is_empty() {
618        return Ok(BTreeMap::new());
619    }
620    let mut imports = BTreeMap::new();
621    for fact in facts.imports() {
622        check()?;
623        if native
624            .relations
625            .get(fact.relation)
626            .is_some_and(|relation| relation.kind == RelationKind::Imports)
627        {
628            imports.insert(fact.span.start, fact.relation);
629        }
630    }
631    let mut paired = BTreeMap::new();
632    let mut offset = 0;
633    for (line, source) in content.split_inclusive('\n').enumerate() {
634        check_parser_iteration(line, check)?;
635        let start = offset + source.len() - source.trim_start().len();
636        offset += source.len();
637        let Some(candidates) = rescued.get(&(line + 1)) else {
638            continue;
639        };
640        let Some(native_index) = imports.get(&start).copied() else {
641            continue;
642        };
643        let expected = bounded_relation_text(source);
644        if let Some(index) = candidates
645            .iter()
646            .copied()
647            .find(|index| graph.relations[*index].target_name == expected)
648        {
649            paired.insert(native_index, index);
650        }
651    }
652    Ok(paired)
653}
654
655/// Reproduce bounded rescue display text without materializing an arbitrarily long line.
656fn bounded_relation_text(source: &str) -> String {
657    let mut text = String::new();
658    let mut chars = 0;
659    'words: for word in source.split_whitespace() {
660        if !text.is_empty() {
661            text.push(' ');
662            chars += 1;
663        }
664        if chars > MAX_SNIPPET_CHARS {
665            break;
666        }
667        for character in word.chars() {
668            text.push(character);
669            chars += 1;
670            if chars > MAX_SNIPPET_CHARS {
671                break 'words;
672            }
673        }
674    }
675    truncate_chars_at_boundary(&text, MAX_SNIPPET_CHARS)
676}
677
678/// Merge native facts while preserving host declarations and distinct call occurrences.
679fn merge_missing_graph_entries_checked<E>(
680    graph: &mut SymbolGraph,
681    embedded: SymbolGraph,
682    compatibility_imports: &BTreeMap<usize, usize>,
683    host_kind: EmbeddedHostKind,
684    check: &mut impl FnMut() -> Result<(), E>,
685    mut ordinals: Option<&mut GraphMergeOrdinals>,
686) -> Result<(), E> {
687    let symbol_identity = |symbol: &CodeSymbol| {
688        (
689            symbol.name.clone(),
690            symbol.kind as u8,
691            symbol.line_start,
692            symbol.line_end,
693            symbol.parent.clone(),
694        )
695    };
696    let mut symbol_indices = BTreeMap::<_, Vec<usize>>::new();
697    for (index, symbol) in graph.symbols.iter().enumerate() {
698        check_parser_iteration(index, check)?;
699        symbol_indices
700            .entry(symbol_identity(symbol))
701            .or_default()
702            .push(index);
703    }
704    for (iteration, symbol) in embedded.symbols.into_iter().enumerate() {
705        check_parser_iteration(iteration, check)?;
706        if graph.symbols.len() >= MAX_SYMBOLS_PER_FILE {
707            break;
708        }
709        if host_kind == EmbeddedHostKind::Component
710            && (symbol.kind == SymbolKind::Import
711                || (symbol.kind == SymbolKind::Value && !symbol.exported))
712        {
713            continue;
714        }
715        let identity = symbol_identity(&symbol);
716        let indices = symbol_indices.entry(identity).or_insert_with(|| {
717            let index = graph.symbols.len();
718            graph.symbols.push(symbol);
719            vec![index]
720        });
721        if let Some(mapping) = ordinals.as_deref_mut()
722            && indices.len() == 1
723        {
724            mapping.symbols[iteration] = SourceSymbolRemap::Retained(indices[0]);
725        }
726    }
727    // Several native declarations coalescing to one display row cannot narrow an overload.
728    if let Some(mapping) = ordinals.as_deref_mut() {
729        let mut counts = BTreeMap::new();
730        for symbol in &mapping.symbols {
731            if let Some(index) = symbol.retained() {
732                *counts.entry(index).or_insert(0_usize) += 1;
733            }
734        }
735        for symbol in &mut mapping.symbols {
736            if let Some(index) = symbol.retained()
737                && counts.get(&index).is_some_and(|count| *count > 1)
738            {
739                *symbol = SourceSymbolRemap::Ambiguous(index);
740            }
741        }
742    }
743    let relation_identity = |relation: &SymbolRelation| {
744        (
745            relation.source_name.clone(),
746            relation.target_name.clone(),
747            relation.kind as u8,
748            relation.line,
749        )
750    };
751    let mut relation_indices = BTreeMap::<_, Vec<usize>>::new();
752    for (index, relation) in graph.relations.iter().enumerate() {
753        check_parser_iteration(index, check)?;
754        if matches!(relation.kind, RelationKind::Calls | RelationKind::Imports)
755            && relation.parser == ParserKind::TreeSitter
756        {
757            continue;
758        }
759        relation_indices
760            .entry(relation_identity(relation))
761            .or_default()
762            .push(index);
763    }
764    let mut claimed_compatibility_rows = BTreeSet::new();
765    for (iteration, relation) in embedded.relations.into_iter().enumerate() {
766        check_parser_iteration(iteration, check)?;
767        if graph.relations.len() >= MAX_RELATIONS_PER_FILE {
768            break;
769        }
770        let indices = relation_indices
771            .entry(relation_identity(&relation))
772            .or_default();
773        let occurrence = matches!(relation.kind, RelationKind::Calls | RelationKind::Imports);
774        let parser = relation.parser;
775        let existing = if occurrence {
776            let mut existing = None;
777            while let Some(index) = indices.pop() {
778                if !claimed_compatibility_rows.contains(&index) {
779                    existing = Some(index);
780                    break;
781                }
782            }
783            existing
784        } else {
785            indices.first().copied()
786        };
787        let owned_import = if existing.is_none() {
788            compatibility_imports
789                .get(&iteration)
790                .copied()
791                .filter(|index| !claimed_compatibility_rows.contains(index))
792        } else {
793            None
794        };
795        let index = if let Some(index) = owned_import {
796            // The native AST owns this line's first import. Replace the rescue
797            // row's whole-line payload with that captured occurrence.
798            graph.relations[index]
799                .target_name
800                .clone_from(&relation.target_name);
801            graph.relations[index].context.clone_from(&relation.context);
802            index
803        } else if let Some(index) = existing {
804            index
805        } else {
806            let index = graph.relations.len();
807            graph.relations.push(relation);
808            if !occurrence {
809                indices.push(index);
810            }
811            index
812        };
813        if occurrence {
814            // This exact occurrence now has native AST proof, even when its row
815            // was first rescued by the host. Keep graph-level host coverage
816            // partial, but do not mix rescue and native trust for one proved edge.
817            graph.relations[index].parser = parser;
818        }
819        // A compatibility row can represent one captured occurrence, not every same-line call.
820        let first_owner = claimed_compatibility_rows.insert(index);
821        if let Some(mapping) = ordinals.as_deref_mut()
822            && (occurrence || first_owner)
823        {
824            mapping.relations[iteration] = Some(index);
825        }
826    }
827    Ok(())
828}
829
830/// Return whether two symbols represent the same declaration.
831fn same_symbol_identity(left: &CodeSymbol, right: &CodeSymbol) -> bool {
832    left.name == right.name
833        && left.kind == right.kind
834        && left.line_start == right.line_start
835        && left.line_end == right.line_end
836        && left.parent == right.parent
837}
838
839/// Return whether two relations represent the same source edge.
840fn same_relation_identity(left: &SymbolRelation, right: &SymbolRelation) -> bool {
841    left.source_name == right.source_name
842        && left.target_name == right.target_name
843        && left.kind == right.kind
844        && left.line == right.line
845}
846
847/// Extract a Composition API binding name from a script setup row.
848fn vue_composition_binding_name(line: &str) -> Option<String> {
849    const MACROS: &[&str] = &[
850        "defineProps",
851        "defineEmits",
852        "defineModel",
853        "defineSlots",
854        "computed",
855        "ref",
856        "shallowRef",
857        "reactive",
858        "toRef",
859        "toRefs",
860        "watch",
861    ];
862    let rest = line
863        .strip_prefix("const ")
864        .or_else(|| line.strip_prefix("let "))
865        .or_else(|| line.strip_prefix("var "))?;
866    let (name, initializer) = rest.split_once('=')?;
867    let name = name.trim();
868    if name.is_empty() {
869        return None;
870    }
871    let initializer = initializer.trim_start();
872    MACROS
873        .iter()
874        .any(|macro_name| vue_initializer_starts_with_macro(initializer, macro_name))
875        .then(|| name.to_string())
876}
877
878/// Return whether a Vue initializer starts with a supported Composition API macro.
879fn vue_initializer_starts_with_macro(initializer: &str, macro_name: &str) -> bool {
880    vue_initializer_is_macro_call(initializer, macro_name)
881        || initializer
882            .strip_prefix("withDefaults(")
883            .is_some_and(|nested| vue_initializer_is_macro_call(nested.trim_start(), macro_name))
884}
885
886/// Return whether an initializer begins with the named macro call.
887fn vue_initializer_is_macro_call(initializer: &str, macro_name: &str) -> bool {
888    let Some(rest) = initializer.strip_prefix(macro_name) else {
889        return false;
890    };
891    let rest = rest.trim_start();
892    rest.starts_with('(') || rest.starts_with('<')
893}
894
895/// Extract Cargo package, workspace, and dependency entries with cooperative parser control.
896fn extract_cargo_manifest_graph_checked<E>(
897    path: &str,
898    language: Option<&str>,
899    content: &str,
900    check: &mut impl FnMut() -> Result<(), E>,
901    semantics: Option<&mut SourceSemanticFacts>,
902) -> Result<SymbolGraph, E> {
903    check()?;
904    let mut graph = empty_graph(path, language, ParserKind::Manifest);
905    let is_lock = match language {
906        Some(language) => language == "cargo-lock",
907        None => path.ends_with("Cargo.lock"),
908    };
909    if is_lock {
910        extract_cargo_lock_packages_checked(&mut graph, content, check)?;
911        check()?;
912        return Ok(graph);
913    }
914    extract_cargo_toml_entries_checked(&mut graph, content, check, semantics)?;
915    check()?;
916    Ok(graph)
917}
918
919/// Extract package names from Cargo.lock with cooperative parser control.
920fn extract_cargo_lock_packages_checked<E>(
921    graph: &mut SymbolGraph,
922    content: &str,
923    check: &mut impl FnMut() -> Result<(), E>,
924) -> Result<(), E> {
925    let Ok(lockfile) = toml::from_str::<TomlValue>(content) else {
926        return Ok(());
927    };
928    check()?;
929    let Some(packages) = lockfile.get("package").and_then(TomlValue::as_array) else {
930        return Ok(());
931    };
932    let mut next_package_line = 0;
933    for (iteration, package) in packages.iter().enumerate() {
934        check_parser_iteration(iteration, check)?;
935        let Some(name) = package
936            .as_table()
937            .and_then(|table| table.get("name"))
938            .and_then(TomlValue::as_str)
939        else {
940            continue;
941        };
942        let line = cargo_lock_name_line_checked(content, name, next_package_line, check)?;
943        if let Some(found_line) = line {
944            next_package_line = found_line;
945        }
946        let line = line.unwrap_or(1);
947        push_symbol(
948            graph,
949            name,
950            SymbolKind::Dependency,
951            line,
952            line,
953            None,
954            Some("cargo-lock-package"),
955            &format!("lock package {name}"),
956        );
957    }
958    Ok(())
959}
960
961/// Return the one-based source line for a package name with cooperative parser control.
962fn cargo_lock_name_line_checked<E>(
963    content: &str,
964    package_name: &str,
965    start_line: usize,
966    check: &mut impl FnMut() -> Result<(), E>,
967) -> Result<Option<usize>, E> {
968    let mut in_package = false;
969    for (iteration, (index, raw_line)) in content.lines().enumerate().skip(start_line).enumerate() {
970        check_parser_iteration(iteration, check)?;
971        let line = raw_line.trim();
972        if line == "[[package]]" {
973            in_package = true;
974            continue;
975        }
976        if line.starts_with('[') {
977            in_package = false;
978        }
979        if in_package
980            && let Some((key, value)) = line.split_once('=')
981            && key.trim() == "name"
982            && value.trim().trim_matches('"') == package_name
983        {
984            return Ok(Some(index + 1));
985        }
986    }
987    Ok(None)
988}
989
990/// Extract package, workspace, and dependencies from Cargo.toml with cooperative parser control.
991fn extract_cargo_toml_entries_checked<E>(
992    graph: &mut SymbolGraph,
993    content: &str,
994    check: &mut impl FnMut() -> Result<(), E>,
995    semantics: Option<&mut SourceSemanticFacts>,
996) -> Result<(), E> {
997    let Ok(manifest) = toml::from_str::<TomlValue>(content) else {
998        return Ok(());
999    };
1000    check()?;
1001    let Some(root) = manifest.as_table() else {
1002        return Ok(());
1003    };
1004    if let Some(facts) = semantics {
1005        facts.cargo = Some(rust_binding_source::cargo_targets(root, check)?);
1006    }
1007    let line_index = CargoTomlLineIndex::new_checked(content, check)?;
1008    if root.contains_key("workspace") {
1009        let line = line_index.section_line("workspace").unwrap_or(1);
1010        push_symbol(
1011            graph,
1012            "workspace",
1013            SymbolKind::Workspace,
1014            line,
1015            line,
1016            None,
1017            Some("cargo-workspace"),
1018            line_index.line_text(line).unwrap_or("[workspace]"),
1019        );
1020    }
1021    if let Some(package) = root.get("package").and_then(TomlValue::as_table)
1022        && let Some(name) = package.get("name").and_then(TomlValue::as_str)
1023    {
1024        let line = line_index.key_line("package", "name").unwrap_or(1);
1025        push_symbol(
1026            graph,
1027            name,
1028            SymbolKind::Package,
1029            line,
1030            line,
1031            None,
1032            Some("cargo-package"),
1033            line_index.line_text(line).unwrap_or(name),
1034        );
1035    }
1036    collect_cargo_dependencies_checked(graph, &line_index, &[], root, check)?;
1037    Ok(())
1038}
1039
1040/// Recursively collect dependency tables from parsed Cargo TOML with cooperative control.
1041fn collect_cargo_dependencies_checked<E>(
1042    graph: &mut SymbolGraph,
1043    line_index: &CargoTomlLineIndex,
1044    path: &[String],
1045    table: &toml::map::Map<String, TomlValue>,
1046    check: &mut impl FnMut() -> Result<(), E>,
1047) -> Result<(), E> {
1048    check()?;
1049    let section = path.join(".");
1050    if is_dependency_table_path(path) {
1051        for (iteration, (name, value)) in table.iter().enumerate() {
1052            check_parser_iteration(iteration, check)?;
1053            let line = line_index
1054                .key_line(&section, name)
1055                .or_else(|| line_index.section_line(&section))
1056                .unwrap_or(1);
1057            let detail = line_index
1058                .line_text(line)
1059                .map_or_else(|| name.as_str(), str::trim);
1060            let dependency_name = manifest_dependency_name(name, value);
1061            push_symbol(
1062                graph,
1063                &dependency_name,
1064                SymbolKind::Dependency,
1065                line,
1066                line,
1067                Some(section.clone()),
1068                Some("cargo-dependency"),
1069                detail,
1070            );
1071            push_relation(
1072                graph,
1073                "cargo",
1074                &dependency_name,
1075                RelationKind::DependsOn,
1076                line,
1077                detail,
1078            );
1079        }
1080        return Ok(());
1081    }
1082    for (iteration, (key, value)) in table.iter().enumerate() {
1083        check_parser_iteration(iteration, check)?;
1084        let Some(child) = value.as_table() else {
1085            continue;
1086        };
1087        let mut child_path = path.to_owned();
1088        child_path.push(key.clone());
1089        collect_cargo_dependencies_checked(graph, line_index, &child_path, child, check)?;
1090    }
1091    Ok(())
1092}
1093
1094/// Return whether a parsed TOML table path declares dependencies.
1095fn is_dependency_table_path(path: &[String]) -> bool {
1096    path.last().is_some_and(|last| {
1097        last == "dependencies" || last == "dev-dependencies" || last == "build-dependencies"
1098    })
1099}
1100
1101/// Return the Cargo dependency package name for normal or renamed dependencies.
1102fn manifest_dependency_name(key: &str, value: &TomlValue) -> String {
1103    value
1104        .as_table()
1105        .and_then(|table| table.get("package"))
1106        .and_then(TomlValue::as_str)
1107        .unwrap_or(key)
1108        .to_string()
1109}
1110
1111/// Source-line lookup for parsed Cargo TOML entries.
1112struct CargoTomlLineIndex<'a> {
1113    /// Original lines.
1114    lines: Vec<&'a str>,
1115    /// Section declaration lines keyed by dotted path.
1116    sections: std::collections::HashMap<String, usize>,
1117    /// Key declaration lines keyed by dotted section and key name.
1118    keys: std::collections::HashMap<(String, String), usize>,
1119}
1120
1121impl<'a> CargoTomlLineIndex<'a> {
1122    /// Build a line index for TOML source positions with cooperative parser control.
1123    fn new_checked<E>(
1124        content: &'a str,
1125        check: &mut impl FnMut() -> Result<(), E>,
1126    ) -> Result<Self, E> {
1127        let lines = content.lines().collect::<Vec<_>>();
1128        let mut sections = std::collections::HashMap::new();
1129        let mut keys = std::collections::HashMap::new();
1130        let mut current_section = String::new();
1131        for (index, raw_line) in lines.iter().enumerate() {
1132            check_parser_iteration(index, check)?;
1133            let line_number = index + 1;
1134            let line = raw_line.trim();
1135            if line.starts_with('[') && line.ends_with(']') {
1136                current_section = normalize_toml_section(line.trim_matches(&['[', ']'][..]).trim());
1137                sections.insert(current_section.clone(), line_number);
1138                continue;
1139            }
1140            let Some((key, _value)) = line.split_once('=') else {
1141                continue;
1142            };
1143            let key = key.trim().trim_matches('"').to_string();
1144            if !key.is_empty() {
1145                keys.insert((current_section.clone(), key), line_number);
1146            }
1147        }
1148        Ok(Self {
1149            lines,
1150            sections,
1151            keys,
1152        })
1153    }
1154
1155    /// Return the source line for a section declaration.
1156    fn section_line(&self, section: &str) -> Option<usize> {
1157        self.sections.get(section).copied()
1158    }
1159
1160    /// Return the source line for a key in a section.
1161    fn key_line(&self, section: &str, key: &str) -> Option<usize> {
1162        self.keys
1163            .get(&(section.to_string(), key.to_string()))
1164            .copied()
1165    }
1166
1167    /// Return source text for a one-based line number.
1168    fn line_text(&self, line: usize) -> Option<&'a str> {
1169        self.lines.get(line.checked_sub(1)?).copied()
1170    }
1171}
1172
1173/// Normalize quoted TOML section components into a dotted lookup key.
1174fn normalize_toml_section(section: &str) -> String {
1175    let mut parts = Vec::new();
1176    let mut current = String::new();
1177    let mut quote: Option<char> = None;
1178    for character in section.chars() {
1179        match (character, quote) {
1180            ('"' | '\'', None) => quote = Some(character),
1181            (value, Some(active)) if value == active => quote = None,
1182            ('.', None) => {
1183                if !current.is_empty() {
1184                    parts.push(current.clone());
1185                    current.clear();
1186                }
1187            }
1188            (value, _) => current.push(value),
1189        }
1190    }
1191    if !current.is_empty() {
1192        parts.push(current);
1193    }
1194    parts.join(".")
1195}
1196
1197/// Tree-sitter extraction result with parse health metadata.
1198struct TreeSitterParse {
1199    /// Extracted symbol graph.
1200    graph: SymbolGraph,
1201    /// Whether tree-sitter found syntax errors while parsing.
1202    had_errors: bool,
1203    /// Whether the extracted facts omit PHP semantics that cannot be proven statically.
1204    incomplete: bool,
1205}
1206
1207/// PHP mixed-grammar result with its grammar-owned opening-tag classification.
1208struct PhpParse {
1209    /// Parsed full-file PHP/mixed tree.
1210    tree: Tree,
1211    /// Whether a PHP opening tag occurs outside PHP literals or comments.
1212    has_opening_tag: bool,
1213}
1214
1215/// Extract a graph through tree-sitter when the language has a grammar.
1216fn extract_tree_sitter_graph<E>(
1217    path: &str,
1218    language: Option<&str>,
1219    content: &str,
1220    check: &mut impl FnMut() -> Result<(), E>,
1221    mut semantics: Option<&mut SourceSemanticFacts>,
1222    regions: Option<&[semantic::embedded_source::EmbeddedScriptRegion]>,
1223) -> Result<Option<TreeSitterParse>, E> {
1224    let Some(language_name) = language else {
1225        return Ok(None);
1226    };
1227    let Some(grammar) = tree_sitter_grammar(language_name) else {
1228        return Ok(None);
1229    };
1230    let (trees, has_php_opening_tag) = if grammar == TreeSitterGrammar::Php {
1231        let Some(parsed) = parse_php_tree(content, check)? else {
1232            return Ok(None);
1233        };
1234        (vec![parsed.tree], Some(parsed.has_opening_tag))
1235    } else {
1236        let Some(parser_language) = tree_sitter_language(language_name) else {
1237            return Ok(None);
1238        };
1239        let trees = if let Some(regions) = regions {
1240            parse_embedded_script_trees(&parser_language, path, content, regions, check)?
1241        } else {
1242            let ecmascript = matches!(
1243                grammar,
1244                TreeSitterGrammar::JavaScript
1245                    | TreeSitterGrammar::TypeScript
1246                    | TreeSitterGrammar::Tsx
1247            );
1248            parse_source_program(&parser_language, content, ecmascript.then_some(path), check)?
1249                .map(|tree| vec![tree])
1250        };
1251        let Some(trees) = trees else {
1252            return Ok(None);
1253        };
1254        (trees, None)
1255    };
1256    check()?;
1257    let mut graph = empty_graph(path, language, ParserKind::TreeSitter);
1258    if has_php_opening_tag == Some(false) {
1259        return Ok(Some(TreeSitterParse {
1260            graph,
1261            had_errors: false,
1262            incomplete: false,
1263        }));
1264    }
1265    let had_errors = trees.iter().any(|tree| tree.root_node().has_error());
1266    let mut incomplete = has_php_opening_tag == Some(true) && had_errors;
1267    let mut traversal = ControlFlow::Continue(());
1268    for (tree_index, tree) in trees.iter().enumerate() {
1269        check()?;
1270        let root = tree.root_node();
1271        let import_start = semantics.as_ref().map(|facts| facts.imports.len());
1272        let mut php_namespace_context = if has_php_opening_tag == Some(true) {
1273            Some(PhpNamespaceContext::from_program(root, content, check)?)
1274        } else {
1275            None
1276        };
1277        traversal = visit_node(
1278            root,
1279            0,
1280            content,
1281            &mut graph,
1282            check,
1283            php_namespace_context.as_mut(),
1284            &mut incomplete,
1285            semantics.as_deref_mut(),
1286        )?;
1287        if matches!(language_name, "javascript" | "typescript" | "tsx")
1288            && let (Some(start), Some(facts)) = (import_start, semantics.as_deref_mut())
1289            && !semantic::ecmascript_context::source_context(
1290                root,
1291                path,
1292                regions
1293                    .and_then(|regions| regions.get(tree_index))
1294                    .map(|region| region.role),
1295                content,
1296                check,
1297            )?
1298            .is_module()
1299        {
1300            for import in &mut facts.imports[start..] {
1301                check()?;
1302                import.module = None;
1303                import.references.clear();
1304            }
1305        }
1306        if traversal.is_break() {
1307            break;
1308        }
1309    }
1310    check()?;
1311    if traversal.is_continue() {
1312        languages::augment_language_graph(&mut graph, content, check)?;
1313        if let Some(facts) = semantics {
1314            if facts.bindings_incomplete {
1315                facts.exports_incomplete = true;
1316                if semantic::provider_for_graph(&graph) == Some(SemanticProviderOwner::Rust) {
1317                    facts.rust = Some(SourceRustFacts::default());
1318                }
1319                for call in &mut facts.calls {
1320                    call.binding = SourceCallBinding::Unresolved;
1321                }
1322            } else {
1323                let roots = trees
1324                    .iter()
1325                    .enumerate()
1326                    .map(|(index, tree)| lexical_bindings::SourceSyntaxRoot {
1327                        node: tree.root_node(),
1328                        role: regions
1329                            .and_then(|regions| regions.get(index))
1330                            .map(|region| region.role),
1331                    })
1332                    .collect::<Vec<_>>();
1333                lexical_bindings::bind_calls(&roots, content, &graph, facts, check)?;
1334            }
1335        }
1336    }
1337    check()?;
1338    Ok(Some(TreeSitterParse {
1339        graph,
1340        had_errors,
1341        incomplete,
1342    }))
1343}
1344
1345/// Precomputed source-order ownership ranges for semicolon PHP namespaces.
1346struct PhpNamespaceContext {
1347    /// Non-overlapping ranges whose declarations belong to a namespace.
1348    ranges: Vec<PhpNamespaceRange>,
1349    /// Next range to inspect while declarations are visited in source order.
1350    next_range: usize,
1351    /// Number of program children examined while building the ranges.
1352    #[cfg(test)]
1353    examined_children: usize,
1354    /// Number of source-order lookups made while visiting top-level nodes.
1355    #[cfg(test)]
1356    parent_lookups: usize,
1357}
1358
1359/// One semicolon namespace's source-order ownership range.
1360struct PhpNamespaceRange {
1361    /// First byte after the namespace declaration.
1362    start_byte: usize,
1363    /// First byte of the next namespace declaration or end of source.
1364    end_byte: usize,
1365    /// Bounded namespace identity; absent when its scope cannot be represented.
1366    name: Option<String>,
1367}
1368
1369impl PhpNamespaceContext {
1370    /// Build namespace ranges in one forward pass over the program children.
1371    fn from_program<E>(
1372        root: Node<'_>,
1373        content: &str,
1374        check: &mut impl FnMut() -> Result<(), E>,
1375    ) -> Result<Self, E> {
1376        let mut context = Self {
1377            ranges: Vec::new(),
1378            next_range: 0,
1379            #[cfg(test)]
1380            examined_children: 0,
1381            #[cfg(test)]
1382            parent_lookups: 0,
1383        };
1384        let mut active = None;
1385        let mut cursor = root.walk();
1386        for child in root.named_children(&mut cursor) {
1387            check()?;
1388            #[cfg(test)]
1389            {
1390                context.examined_children += 1;
1391            }
1392            if child.kind() != "namespace_definition" {
1393                continue;
1394            }
1395            if let Some((start_byte, name)) = active.take() {
1396                context.ranges.push(PhpNamespaceRange {
1397                    start_byte,
1398                    end_byte: child.start_byte(),
1399                    name,
1400                });
1401            }
1402            if child.child_by_field_name("body").is_none() {
1403                let name = (!child.has_error())
1404                    .then(|| php_bounded_namespace_name(child, content))
1405                    .flatten();
1406                active = Some((child.end_byte(), name));
1407            }
1408        }
1409        if let Some((start_byte, name)) = active {
1410            context.ranges.push(PhpNamespaceRange {
1411                start_byte,
1412                end_byte: content.len(),
1413                name,
1414            });
1415        }
1416        Ok(context)
1417    }
1418
1419    /// Return the active namespace for the next source-order top-level node.
1420    fn parent_for(&mut self, node: Node<'_>) -> Option<String> {
1421        #[cfg(test)]
1422        {
1423            self.parent_lookups += 1;
1424        }
1425        self.range_for(node).and_then(|range| range.name.clone())
1426    }
1427
1428    /// Locate the next node's namespace without allocating its identity.
1429    fn range_for(&mut self, node: Node<'_>) -> Option<&PhpNamespaceRange> {
1430        while self
1431            .ranges
1432            .get(self.next_range)
1433            .is_some_and(|range| range.end_byte <= node.start_byte())
1434        {
1435            self.next_range += 1;
1436        }
1437        self.ranges.get(self.next_range).filter(|range| {
1438            range.start_byte <= node.start_byte() && node.start_byte() < range.end_byte
1439        })
1440    }
1441}
1442
1443/// Return a namespace name only when its compact identity can remain bounded.
1444fn php_bounded_namespace_name(node: Node<'_>, content: &str) -> Option<String> {
1445    let name = node.child_by_field_name("name")?;
1446    php_bounded_name_text(name, content)
1447}
1448
1449/// Bound PHP identities before copying or composing their source text.
1450fn php_bounded_name_text(node: Node<'_>, content: &str) -> Option<String> {
1451    let text = node.utf8_text(content.as_bytes()).ok()?;
1452    if text.chars().take(MAX_SNIPPET_CHARS + 1).count() > MAX_SNIPPET_CHARS {
1453        return None;
1454    }
1455    named_text(node, content)
1456}
1457
1458/// Select the official PHP-only or mixed grammar from their parsed roots.
1459fn parse_php_tree<E>(
1460    content: &str,
1461    check: &mut impl FnMut() -> Result<(), E>,
1462) -> Result<Option<PhpParse>, E> {
1463    let mixed_language: Language = tree_sitter_php::LANGUAGE_PHP.into();
1464    let Some(mixed) = parse_tree_sitter_language(&mixed_language, content, check)? else {
1465        return Ok(None);
1466    };
1467    let mut examined_nodes = 0;
1468    let Some(first_tag_start) = first_php_tag_start(mixed.root_node(), check, &mut examined_nodes)?
1469    else {
1470        return Ok(Some(PhpParse {
1471            tree: mixed,
1472            has_opening_tag: false,
1473        }));
1474    };
1475    let first_content_start = content.find(|character: char| !character.is_whitespace());
1476    let first_tag_is_xml = content
1477        .get(first_tag_start..)
1478        .is_some_and(is_xml_declaration_start);
1479    if !mixed.root_node().has_error()
1480        && first_content_start == Some(first_tag_start)
1481        && !first_tag_is_xml
1482    {
1483        return Ok(Some(PhpParse {
1484            tree: mixed,
1485            has_opening_tag: true,
1486        }));
1487    }
1488
1489    // The PHP-only grammar is only a bounded probe for opening tags that the
1490    // mixed grammar can see inside a literal or comment. The full-file result
1491    // remains the mixed grammar so tagless `.php` source is represented as
1492    // inline text instead of executable PHP.
1493    let php_only_language: Language = tree_sitter_php::LANGUAGE_PHP_ONLY.into();
1494    let Some(php_only) = parse_tree_sitter_language(&php_only_language, content, check)? else {
1495        return Ok(Some(PhpParse {
1496            tree: mixed,
1497            has_opening_tag: true,
1498        }));
1499    };
1500    let has_opening_tag = tree_contains_php_tag_outside_literals(
1501        mixed.root_node(),
1502        php_only.root_node(),
1503        content,
1504        check,
1505        &mut examined_nodes,
1506    )?;
1507    Ok(Some(PhpParse {
1508        tree: mixed,
1509        has_opening_tag,
1510    }))
1511}
1512
1513/// Return a tree-sitter language for supported source families.
1514fn tree_sitter_language(language: &str) -> Option<Language> {
1515    Some(match tree_sitter_grammar(language)? {
1516        TreeSitterGrammar::Rust => tree_sitter_rust::LANGUAGE.into(),
1517        TreeSitterGrammar::Python => tree_sitter_python::LANGUAGE.into(),
1518        TreeSitterGrammar::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
1519        TreeSitterGrammar::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1520        TreeSitterGrammar::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
1521        TreeSitterGrammar::Java => tree_sitter_java::LANGUAGE.into(),
1522        TreeSitterGrammar::Kotlin => tree_sitter_kotlin_ng::LANGUAGE.into(),
1523        TreeSitterGrammar::CSharp => tree_sitter_c_sharp::LANGUAGE.into(),
1524        TreeSitterGrammar::Go => tree_sitter_go::LANGUAGE.into(),
1525        TreeSitterGrammar::ObjectiveC => tree_sitter_objc::LANGUAGE.into(),
1526        TreeSitterGrammar::Zig => tree_sitter_zig::LANGUAGE.into(),
1527        TreeSitterGrammar::C => tree_sitter_c::LANGUAGE.into(),
1528        TreeSitterGrammar::Cpp => tree_sitter_cpp::LANGUAGE.into(),
1529        TreeSitterGrammar::Php => tree_sitter_php::LANGUAGE_PHP_ONLY.into(),
1530    })
1531}
1532
1533/// Parse source with one pinned tree-sitter grammar while observing cancellation.
1534fn parse_tree_sitter_language<E>(
1535    parser_language: &Language,
1536    content: &str,
1537    check: &mut impl FnMut() -> Result<(), E>,
1538) -> Result<Option<Tree>, E> {
1539    parse_source_program(parser_language, content, None, check)
1540}
1541
1542/// Parse one program, repairing contextual ECMAScript tokens only when necessary.
1543fn parse_source_program<E>(
1544    parser_language: &Language,
1545    content: &str,
1546    ecmascript_path: Option<&str>,
1547    check: &mut impl FnMut() -> Result<(), E>,
1548) -> Result<Option<Tree>, E> {
1549    check()?;
1550    let mut parser = Parser::new();
1551    if parser.set_language(parser_language).is_err() {
1552        return Ok(None);
1553    }
1554    let Some(tree) = parse_tree_with_parser(&mut parser, content, check)? else {
1555        return Ok(None);
1556    };
1557    if let Some(path) = ecmascript_path {
1558        return repair_contextual_tree(
1559            &mut parser,
1560            tree,
1561            path,
1562            None,
1563            content,
1564            SourceByteSpan {
1565                start: 0,
1566                end: content.len(),
1567            },
1568            &mut None,
1569            check,
1570        );
1571    }
1572    Ok(Some(tree))
1573}
1574
1575/// Prove contextual repairs through the same parser and actual candidate scopes.
1576///
1577/// At most two reparses share one private buffer: the second only restores tokens
1578/// proved to be reserved operators in the first candidate. No per-call reparsing.
1579fn repair_contextual_tree<E>(
1580    parser: &mut Parser,
1581    original: Tree,
1582    path: &str,
1583    role: Option<semantic::embedded_source::EmbeddedScriptRole>,
1584    content: &str,
1585    span: SourceByteSpan,
1586    projected: &mut Option<String>,
1587    check: &mut impl FnMut() -> Result<(), E>,
1588) -> Result<Option<Tree>, E> {
1589    use semantic::ecmascript_context::{
1590        ContextualProjectionValidation, project_contextual_identifiers, source_context,
1591        validate_contextual_projection,
1592    };
1593    let context = source_context(original.root_node(), path, role, content, check)?;
1594    if !project_contextual_identifiers(
1595        original.root_node(),
1596        context,
1597        content,
1598        span,
1599        projected,
1600        check,
1601    )? {
1602        return Ok(Some(original));
1603    }
1604    let Some(projected) = projected else {
1605        return Ok(Some(original));
1606    };
1607    for _ in 0..2 {
1608        let Some(candidate) = parse_tree_with_parser(parser, projected, check)? else {
1609            return Ok(None);
1610        };
1611        if candidate.root_node().has_error() {
1612            return Ok(Some(original));
1613        }
1614        match validate_contextual_projection(
1615            candidate.root_node(),
1616            path,
1617            role,
1618            span,
1619            content,
1620            projected,
1621            check,
1622        )? {
1623            ContextualProjectionValidation::Valid => return Ok(Some(candidate)),
1624            ContextualProjectionValidation::Restored => {}
1625            ContextualProjectionValidation::Rejected => return Ok(Some(original)),
1626        }
1627    }
1628    Ok(Some(original))
1629}
1630
1631/// Parse each admitted program once with one reused grammar parser and budget.
1632///
1633/// Trees retain original offsets; the input buffer is borrowed, never copied per
1634/// region. Separate ranges prevent cross-program recovery and ASI ambiguity.
1635fn parse_embedded_script_trees<E>(
1636    parser_language: &Language,
1637    path: &str,
1638    content: &str,
1639    regions: &[semantic::embedded_source::EmbeddedScriptRegion],
1640    check: &mut impl FnMut() -> Result<(), E>,
1641) -> Result<Option<Vec<Tree>>, E> {
1642    check()?;
1643    let mut parser = Parser::new();
1644    if parser.set_language(parser_language).is_err()
1645        || regions.len() > semantic::embedded_source::MAX_EMBEDDED_SCRIPT_REGIONS
1646    {
1647        return Ok(None);
1648    }
1649    let mut trees = Vec::with_capacity(regions.len());
1650    let mut projected = None;
1651    let mut offset = 0;
1652    let mut point = tree_sitter::Point { row: 0, column: 0 };
1653    for region in regions {
1654        check()?;
1655        if region.span.start < offset
1656            || region.span.start > region.span.end
1657            || region.span.end > content.len()
1658            || !content.is_char_boundary(region.span.start)
1659            || !content.is_char_boundary(region.span.end)
1660        {
1661            return Ok(None);
1662        }
1663        advance_source_point(
1664            content.as_bytes(),
1665            &mut offset,
1666            &mut point,
1667            region.span.start,
1668            check,
1669        )?;
1670        let start_point = point;
1671        advance_source_point(
1672            content.as_bytes(),
1673            &mut offset,
1674            &mut point,
1675            region.span.end,
1676            check,
1677        )?;
1678        let range = tree_sitter::Range {
1679            start_byte: region.span.start,
1680            end_byte: region.span.end,
1681            start_point,
1682            end_point: point,
1683        };
1684        if parser.set_included_ranges(&[range]).is_err() {
1685            return Ok(None);
1686        }
1687        let Some(tree) = parse_tree_with_parser(&mut parser, content, check)? else {
1688            return Ok(None);
1689        };
1690        let Some(tree) = repair_contextual_tree(
1691            &mut parser,
1692            tree,
1693            path,
1694            Some(region.role),
1695            content,
1696            region.span,
1697            &mut projected,
1698            check,
1699        )?
1700        else {
1701            return Ok(None);
1702        };
1703        trees.push(tree);
1704    }
1705    Ok(Some(trees))
1706}
1707
1708/// Map sorted region endpoints in one bounded pass without prefix rescans.
1709fn advance_source_point<E>(
1710    bytes: &[u8],
1711    offset: &mut usize,
1712    point: &mut tree_sitter::Point,
1713    end: usize,
1714    check: &mut impl FnMut() -> Result<(), E>,
1715) -> Result<(), E> {
1716    while *offset < end {
1717        check_parser_iteration(*offset, check)?;
1718        if bytes[*offset] == b'\n' {
1719            point.row += 1;
1720            point.column = 0;
1721        } else {
1722            point.column += 1;
1723        }
1724        *offset += 1;
1725    }
1726    Ok(())
1727}
1728
1729/// Reuse native progress cancellation without resetting the operation budget.
1730fn parse_tree_with_parser<E>(
1731    parser: &mut Parser,
1732    content: &str,
1733    check: &mut impl FnMut() -> Result<(), E>,
1734) -> Result<Option<Tree>, E> {
1735    check()?;
1736    let mut parse_failure = None;
1737    let mut progress = |_: &tree_sitter::ParseState| match check() {
1738        Ok(()) => ControlFlow::Continue(()),
1739        Err(error) => {
1740            parse_failure = Some(error);
1741            ControlFlow::Break(())
1742        }
1743    };
1744    let bytes = content.as_bytes();
1745    let mut read = |offset, _| bytes.get(offset..).unwrap_or_default();
1746    let tree = parser.parse_with_options(
1747        &mut read,
1748        None,
1749        Some(ParseOptions::new().progress_callback(&mut progress)),
1750    );
1751    if let Some(error) = parse_failure {
1752        return Err(error);
1753    }
1754    check()?;
1755    Ok(tree)
1756}
1757
1758/// Return the first opening-tag byte offset in a mixed PHP parse.
1759fn first_php_tag_start<E>(
1760    node: Node<'_>,
1761    check: &mut impl FnMut() -> Result<(), E>,
1762    examined_nodes: &mut usize,
1763) -> Result<Option<usize>, E> {
1764    *examined_nodes += 1;
1765    check_parser_iteration(*examined_nodes, check)?;
1766    if node.kind() == "php_tag" {
1767        return Ok(Some(node.start_byte()));
1768    }
1769    let mut cursor = node.walk();
1770    for child in node.children(&mut cursor) {
1771        if let Some(start) = first_php_tag_start(child, check, examined_nodes)? {
1772            return Ok(Some(start));
1773        }
1774    }
1775    Ok(None)
1776}
1777
1778/// Return whether a mixed parse contains a tag outside a PHP literal or comment.
1779fn tree_contains_php_tag_outside_literals<E>(
1780    mixed: Node<'_>,
1781    php_only: Node<'_>,
1782    content: &str,
1783    check: &mut impl FnMut() -> Result<(), E>,
1784    examined_nodes: &mut usize,
1785) -> Result<bool, E> {
1786    let mut opaque_ranges = Vec::new();
1787    collect_php_only_opaque_ranges(php_only, &mut opaque_ranges, check, examined_nodes)?;
1788    let mut next_opaque = 0;
1789    tree_contains_php_tag_outside_ranges(
1790        mixed,
1791        &opaque_ranges,
1792        &mut next_opaque,
1793        content,
1794        check,
1795        examined_nodes,
1796    )
1797}
1798
1799/// Collect PHP literal/comment ranges in source order and mark top-level output nodes.
1800fn collect_php_only_opaque_ranges<E>(
1801    node: Node<'_>,
1802    ranges: &mut Vec<(usize, usize, bool)>,
1803    check: &mut impl FnMut() -> Result<(), E>,
1804    examined_nodes: &mut usize,
1805) -> Result<(), E> {
1806    *examined_nodes += 1;
1807    check_parser_iteration(*examined_nodes, check)?;
1808    if is_php_opaque_node(node.kind()) {
1809        let top_level_output = node
1810            .parent()
1811            .is_some_and(|parent| parent.kind() == "program");
1812        ranges.push((node.start_byte(), node.end_byte(), top_level_output));
1813        return Ok(());
1814    }
1815    let mut cursor = node.walk();
1816    for child in node.children(&mut cursor) {
1817        collect_php_only_opaque_ranges(child, ranges, check, examined_nodes)?;
1818    }
1819    Ok(())
1820}
1821
1822/// Return whether a mixed parse contains a tag outside the sorted opaque ranges.
1823fn tree_contains_php_tag_outside_ranges<E>(
1824    node: Node<'_>,
1825    opaque_ranges: &[(usize, usize, bool)],
1826    next_opaque: &mut usize,
1827    content: &str,
1828    check: &mut impl FnMut() -> Result<(), E>,
1829    examined_nodes: &mut usize,
1830) -> Result<bool, E> {
1831    *examined_nodes += 1;
1832    check_parser_iteration(*examined_nodes, check)?;
1833    if node.kind() == "php_tag"
1834        && !content
1835            .get(node.start_byte()..)
1836            .is_some_and(is_xml_declaration_start)
1837    {
1838        while *next_opaque < opaque_ranges.len()
1839            && opaque_ranges[*next_opaque].1 <= node.start_byte()
1840        {
1841            *next_opaque += 1;
1842        }
1843        let outside_opaque_range =
1844            opaque_ranges
1845                .get(*next_opaque)
1846                .is_none_or(|&(start, end, top_level_output)| {
1847                    start >= node.end_byte()
1848                        || end <= node.start_byte()
1849                        || (top_level_output && is_php_inline_output_opening(node, content))
1850                });
1851        if outside_opaque_range {
1852            return Ok(true);
1853        }
1854    }
1855    let mut cursor = node.walk();
1856    for child in node.children(&mut cursor) {
1857        if tree_contains_php_tag_outside_ranges(
1858            child,
1859            opaque_ranges,
1860            next_opaque,
1861            content,
1862            check,
1863            examined_nodes,
1864        )? {
1865            return Ok(true);
1866        }
1867    }
1868    Ok(false)
1869}
1870
1871/// Distinguish an XML declaration from short-tag PHP calling an `xml` identifier.
1872fn is_xml_declaration_start(content: &str) -> bool {
1873    let Some(tail) = content.strip_prefix("<?xml") else {
1874        return false;
1875    };
1876    let trimmed = tail.trim_start_matches([' ', '\t', '\r', '\n']);
1877    tail.len() != trimmed.len()
1878        && trimmed.strip_prefix("version").is_some_and(|version| {
1879            version
1880                .trim_start_matches([' ', '\t', '\r', '\n'])
1881                .starts_with('=')
1882        })
1883}
1884
1885/// Return whether inline output text directly precedes a PHP opening tag.
1886fn is_php_inline_output_opening(node: Node<'_>, content: &str) -> bool {
1887    if node.kind() != "php_tag"
1888        || !node_text(node, content)
1889            .is_some_and(|tag| tag == "<?" || tag == "<?=" || tag.eq_ignore_ascii_case("<?php"))
1890    {
1891        return false;
1892    }
1893    let Some(parent) = node.parent() else {
1894        return false;
1895    };
1896    if parent.kind() != "program" {
1897        return false;
1898    }
1899    let mut cursor = parent.walk();
1900    let mut previous = None;
1901    for sibling in parent.children(&mut cursor) {
1902        if sibling.kind() == node.kind()
1903            && sibling.start_byte() == node.start_byte()
1904            && sibling.end_byte() == node.end_byte()
1905        {
1906            break;
1907        }
1908        previous = Some(sibling);
1909    }
1910    previous.is_some_and(|text| text.kind() == "text" && text.end_byte() == node.start_byte())
1911}
1912
1913/// Return whether the PHP-only parse node is opaque to mixed-grammar tags.
1914fn is_php_opaque_node(kind: &str) -> bool {
1915    matches!(
1916        kind,
1917        "comment"
1918            | "encapsed_string"
1919            | "heredoc"
1920            | "nowdoc"
1921            | "shell_command_expression"
1922            | "string"
1923    )
1924}
1925
1926/// Return whether the official PHP grammars recognize an opening tag.
1927#[cfg(test)]
1928fn contains_php_opening_tag(content: &str) -> bool {
1929    let mut check = || Ok::<(), Infallible>(());
1930    parse_php_tree(content, &mut check)
1931        .ok()
1932        .flatten()
1933        .is_some_and(|parsed| parsed.has_opening_tag)
1934}
1935
1936/// Recognize the standalone outer-scope directive before visiting archive data.
1937fn is_php_compiler_halt(node: Node<'_>, content: &str) -> bool {
1938    if node.kind() != "expression_statement" || node.has_error() {
1939        return false;
1940    }
1941    let Some(parent) = node.parent() else {
1942        return false;
1943    };
1944    if parent.kind() != "program"
1945        && !(parent.kind() == "compound_statement"
1946            && parent
1947                .parent()
1948                .is_some_and(|owner| owner.kind() == "namespace_definition"))
1949    {
1950        return false;
1951    }
1952    let Some(call) = node.named_child(0) else {
1953        return false;
1954    };
1955    if call.kind() != "function_call_expression" {
1956        return false;
1957    }
1958    let Some(function) = call.child_by_field_name("function") else {
1959        return false;
1960    };
1961    if function.kind() != "name"
1962        || !content[function.byte_range()].eq_ignore_ascii_case("__halt_compiler")
1963    {
1964        return false;
1965    }
1966    let Some(arguments) = call.child_by_field_name("arguments") else {
1967        return false;
1968    };
1969    let mut cursor = arguments.walk();
1970    arguments
1971        .named_children(&mut cursor)
1972        .all(|child| child.kind() == "comment")
1973}
1974
1975/// Recursively inspect one tree-sitter node.
1976fn visit_node<E>(
1977    node: Node<'_>,
1978    depth: usize,
1979    content: &str,
1980    graph: &mut SymbolGraph,
1981    check: &mut impl FnMut() -> Result<(), E>,
1982    mut php_namespace_context: Option<&mut PhpNamespaceContext>,
1983    incomplete: &mut bool,
1984    mut semantics: Option<&mut SourceSemanticFacts>,
1985) -> Result<ControlFlow<()>, E> {
1986    check()?;
1987    if depth >= MAX_TREE_SITTER_AST_DEPTH {
1988        *incomplete = true;
1989        if let Some(facts) = semantics.as_deref_mut() {
1990            facts.bindings_incomplete = true;
1991        }
1992        return Ok(ControlFlow::Continue(()));
1993    }
1994    if is_php_language(graph.language.as_deref()) && is_php_compiler_halt(node, content) {
1995        return Ok(ControlFlow::Break(()));
1996    }
1997    if is_php_language(graph.language.as_deref())
1998        && (matches!(node.kind(), "anonymous_function" | "arrow_function")
1999            || (node.kind() == "namespace_definition"
2000                && node.child_by_field_name("name").is_some()
2001                && php_bounded_namespace_name(node, content).is_none())
2002            || php_namespace_context.as_deref_mut().is_some_and(|context| {
2003                context
2004                    .range_for(node)
2005                    .is_some_and(|range| range.name.is_none())
2006            }))
2007    {
2008        *incomplete = true;
2009        return Ok(ControlFlow::Continue(()));
2010    }
2011    if is_php_language(graph.language.as_deref()) && php_node_is_incomplete(node, content) {
2012        *incomplete = true;
2013    }
2014    if is_php_language(graph.language.as_deref()) {
2015        *incomplete |= graph.relations.len() >= MAX_RELATIONS_PER_FILE
2016            && (is_php_trait_use_declaration(node)
2017                || is_import_node(node.kind())
2018                || is_call_node(node.kind()));
2019    }
2020    let kind = source_declaration_kind(node.kind(), graph.language.as_deref());
2021    if let Some(kind) = kind
2022        && should_emit_declaration_symbol(node, content)
2023    {
2024        let at_symbol_limit = graph.symbols.len() >= MAX_SYMBOLS_PER_FILE;
2025        let admitted = !at_symbol_limit
2026            && push_tree_symbol(
2027                graph,
2028                node,
2029                content,
2030                if is_php_language(graph.language.as_deref())
2031                    && node.kind() == "function_definition"
2032                {
2033                    // PHP methods have their own grammar node; nested functions stay functions.
2034                    kind
2035                } else {
2036                    effective_declaration_kind(node, kind)
2037                },
2038                php_namespace_context.as_deref_mut(),
2039            );
2040        if at_symbol_limit {
2041            graph.parser = ParserKind::Fallback;
2042            *incomplete = true;
2043            if let Some(facts) = semantics.as_deref_mut() {
2044                facts.bindings_incomplete = true;
2045            }
2046        }
2047        if !admitted && is_php_language(graph.language.as_deref()) {
2048            *incomplete = true;
2049            return Ok(ControlFlow::Continue(()));
2050        }
2051    }
2052    let import_like = owns_import_occurrence(node, graph);
2053    if graph.relations.len() >= MAX_RELATIONS_PER_FILE && import_like {
2054        graph.parser = ParserKind::Fallback;
2055        *incomplete = true;
2056        if let Some(facts) = semantics.as_deref_mut() {
2057            facts.bindings_incomplete = true;
2058        }
2059    }
2060    if graph.relations.len() < MAX_RELATIONS_PER_FILE {
2061        if is_php_trait_use_declaration(node) {
2062            push_php_trait_use_relations(graph, node, content);
2063        } else if import_like {
2064            let relation_start = graph.relations.len();
2065            let rust_sources = push_import_relation(graph, node, content, check)?;
2066            *incomplete |= rust_sources
2067                .as_ref()
2068                .is_some_and(|sources| !sources.complete);
2069            if let Some(facts) = semantics.as_deref_mut() {
2070                capture_import_occurrences(
2071                    facts,
2072                    graph,
2073                    node,
2074                    content,
2075                    relation_start,
2076                    rust_sources,
2077                    check,
2078                )?;
2079            }
2080        } else if is_call_node(node.kind()) {
2081            let relation_start = graph.relations.len();
2082            push_call_relation(graph, node, content, php_namespace_context.as_deref_mut());
2083            if let Some(facts) = semantics.as_deref_mut() {
2084                for relation in relation_start..graph.relations.len() {
2085                    if graph.relations[relation].kind == RelationKind::Calls {
2086                        facts.calls.push(SourceCallFact {
2087                            relation,
2088                            source_span: None,
2089                            owner: None,
2090                            span: SourceByteSpan {
2091                                start: node.start_byte(),
2092                                end: node.end_byte(),
2093                            },
2094                            binding: if is_php_language(graph.language.as_deref()) {
2095                                SourceCallBinding::PhpNamespace
2096                            } else {
2097                                SourceCallBinding::Unresolved
2098                            },
2099                        });
2100                    }
2101                }
2102            }
2103        }
2104    }
2105    let mut cursor = node.walk();
2106    for child in node.named_children(&mut cursor) {
2107        if visit_node(
2108            child,
2109            depth + 1,
2110            content,
2111            graph,
2112            check,
2113            php_namespace_context.as_deref_mut(),
2114            incomplete,
2115            semantics.as_deref_mut(),
2116        )?
2117        .is_break()
2118        {
2119            return Ok(ControlFlow::Break(()));
2120        }
2121    }
2122    Ok(ControlFlow::Continue(()))
2123}
2124
2125/// Associate canonical references only with imports admitted by their source grammar.
2126fn capture_import_occurrences<E>(
2127    facts: &mut SourceSemanticFacts,
2128    graph: &SymbolGraph,
2129    node: Node<'_>,
2130    content: &str,
2131    relation_start: usize,
2132    rust_sources: Option<rust_use_source::RustUseSources>,
2133    check: &mut impl FnMut() -> Result<(), E>,
2134) -> Result<(), E> {
2135    let owner = semantic::provider_for_graph(graph);
2136    let mut rust_sources = rust_sources
2137        .map_or_else(Vec::new, |sources| {
2138            facts.bindings_incomplete |= !sources.complete;
2139            sources.facts
2140        })
2141        .into_iter();
2142    // Rust grouped-use extraction already validates independent leaf facts; an
2143    // unsupported sibling must not erase its separately admitted valid leaves.
2144    let source_valid = !node.has_error() || owner == Some(SemanticProviderOwner::Rust);
2145    for (relation, fact) in graph.relations.iter().enumerate().skip(relation_start) {
2146        if fact.kind != RelationKind::Imports {
2147            continue;
2148        }
2149        check()?;
2150        let proof = if owner == Some(SemanticProviderOwner::EcmaScript) {
2151            semantic::ecmascript_source_bindings(node, content, check)?
2152        } else {
2153            let references = if source_valid {
2154                owner.map_or_else(Vec::new, |owner| {
2155                    semantic::parse_imports(owner, &fact.target_name)
2156                })
2157            } else {
2158                Vec::new()
2159            };
2160            semantic::SourceImportProof {
2161                module: references
2162                    .first()
2163                    .map(|reference| reference.module().to_string()),
2164                references,
2165            }
2166        };
2167        facts.imports.push(SourceImportFact {
2168            relation,
2169            source_span: None,
2170            span: SourceByteSpan {
2171                start: node.start_byte(),
2172                end: node.end_byte(),
2173            },
2174            module: proof.module,
2175            references: proof.references,
2176            rust: rust_sources
2177                .next()
2178                .and_then(|fact| fact.binding)
2179                .map(std::sync::Arc::new),
2180            rust_toolchain: None,
2181        });
2182    }
2183    Ok(())
2184}
2185
2186/// Refine a declaration kind using surrounding syntax context.
2187fn effective_declaration_kind(node: Node<'_>, kind: SymbolKind) -> SymbolKind {
2188    if node.kind() == "record_declaration"
2189        && node
2190            .children(&mut node.walk())
2191            .any(|child| child.kind() == "struct")
2192    {
2193        return SymbolKind::Struct;
2194    }
2195    if kind == SymbolKind::Function && declaration_is_method_context(node) {
2196        return SymbolKind::Method;
2197    }
2198    if kind == SymbolKind::Value
2199        && !is_local_value_declaration(node)
2200        && declaration_has_direct_callable_initializer(node)
2201    {
2202        return SymbolKind::Function;
2203    }
2204    if kind == SymbolKind::Type {
2205        if has_descendant_kind(node, &["struct_type"]) {
2206            return SymbolKind::Struct;
2207        }
2208        if has_descendant_kind(node, &["interface_type"]) {
2209            return SymbolKind::Interface;
2210        }
2211    }
2212    kind
2213}
2214
2215/// Return whether a function-like declaration belongs to an enclosing type.
2216fn declaration_is_method_context(node: Node<'_>) -> bool {
2217    matches!(
2218        node.kind(),
2219        "function_item" | "function_definition" | "function_declaration" | "function_declarator"
2220    ) && (has_ancestor_kind(node.parent(), "impl_item")
2221        || has_ancestor_kind(node.parent(), "class_definition")
2222        || has_ancestor_kind(node.parent(), "class_declaration")
2223        || has_ancestor_kind(node.parent(), "class_body")
2224        || has_ancestor_kind(node.parent(), "class_specifier")
2225        || has_ancestor_kind(node.parent(), "struct_specifier")
2226        || has_ancestor_kind(node.parent(), "union_specifier")
2227        || has_ancestor_kind(node.parent(), "interface_declaration")
2228        || has_ancestor_kind(node.parent(), "trait_declaration"))
2229}
2230
2231/// Return whether this declaration node should become its own symbol row.
2232fn should_emit_declaration_symbol(node: Node<'_>, content: &str) -> bool {
2233    if node.kind() == "namespace_definition" && node.child_by_field_name("name").is_none() {
2234        return false;
2235    }
2236    if is_inside_php_anonymous_class(node) {
2237        return false;
2238    }
2239    if is_php_trait_use_declaration(node) {
2240        return false;
2241    }
2242    if is_object_literal_method(node) {
2243        return object_literal_method_owner(node, content).is_some_and(|owner| owner.exported);
2244    }
2245    if node.kind() == "field_declaration"
2246        && has_descendant_kind(node, &["function_declarator", "method_declarator"])
2247    {
2248        return false;
2249    }
2250    if node.kind() == "property_declaration" && has_descendant_kind(node, &["property_element"]) {
2251        return false;
2252    }
2253    if node.kind() == "const_declaration" && has_descendant_kind(node, &["const_element"]) {
2254        return false;
2255    }
2256    if matches!(node.kind(), "function_declarator" | "method_declarator") {
2257        if is_type_member_declarator(node) {
2258            return true;
2259        }
2260        return !has_declaration_ancestor(node.parent());
2261    }
2262    true
2263}
2264
2265/// Return whether a C/C++ declarator is a type member prototype.
2266fn is_type_member_declarator(node: Node<'_>) -> bool {
2267    has_ancestor_kind(node.parent(), "field_declaration")
2268        && (has_ancestor_kind(node.parent(), "class_specifier")
2269            || has_ancestor_kind(node.parent(), "struct_specifier")
2270            || has_ancestor_kind(node.parent(), "union_specifier"))
2271        && !has_ancestor_kind(node.parent(), "function_definition")
2272}
2273
2274/// Return whether a parent chain already has a declaration symbol owner.
2275fn has_declaration_ancestor(mut node: Option<Node<'_>>) -> bool {
2276    while let Some(current) = node {
2277        if declaration_kind(current.kind()).is_some() {
2278            return true;
2279        }
2280        node = current.parent();
2281    }
2282    false
2283}
2284
2285/// Return whether a value declaration initializes directly to a callable value.
2286fn declaration_has_direct_callable_initializer(node: Node<'_>) -> bool {
2287    if !matches!(
2288        node.kind(),
2289        "lexical_declaration" | "variable_declaration" | "variable_statement" | "var_declaration"
2290    ) {
2291        return false;
2292    }
2293    first_declaration_initializer(node).is_some_and(|initializer| {
2294        initializer
2295            .parent()
2296            .filter(|declarator| !declarator_has_recovery_error(*declarator))
2297            .and_then(|declarator| declarator.child_by_field_name("name"))
2298            .is_some_and(|name| matches!(name.kind(), "identifier" | "simple_identifier"))
2299            && matches!(
2300                initializer.kind(),
2301                "arrow_function"
2302                    | "function"
2303                    | "function_expression"
2304                    | "generator_function"
2305                    | "lambda_expression"
2306            )
2307    })
2308}
2309
2310/// Keep an initializer recovery error with its declarator, not a valid sibling.
2311fn declarator_has_recovery_error(node: Node<'_>) -> bool {
2312    if node.has_error() || node.is_missing() {
2313        return true;
2314    }
2315    let mut sibling = node.next_sibling();
2316    while let Some(current) = sibling {
2317        if current.kind() != "comment" {
2318            return current.is_error() || current.is_missing();
2319        }
2320        sibling = current.next_sibling();
2321    }
2322    false
2323}
2324
2325/// Return the first direct declaration initializer.
2326fn first_declaration_initializer(node: Node<'_>) -> Option<Node<'_>> {
2327    if let Some(value) = node.child_by_field_name("value") {
2328        return Some(value);
2329    }
2330    let mut cursor = node.walk();
2331    for child in node.named_children(&mut cursor) {
2332        if let Some(value) = child.child_by_field_name("value") {
2333            return Some(value);
2334        }
2335    }
2336    None
2337}
2338
2339/// Return whether a declaration is a local binding inside a callable body.
2340fn is_local_value_declaration(node: Node<'_>) -> bool {
2341    matches!(
2342        node.kind(),
2343        "lexical_declaration" | "variable_declaration" | "variable_statement" | "var_declaration"
2344    ) && has_ancestor_kind_any(
2345        node.parent(),
2346        &[
2347            "arrow_function",
2348            "function",
2349            "function_expression",
2350            "function_declaration",
2351            "generator_function_declaration",
2352            "generator_function",
2353            "method_definition",
2354            "method_declaration",
2355            "function_item",
2356            "function_definition",
2357            "function_declaration_with_receiver",
2358            "func_literal",
2359        ],
2360    )
2361}
2362
2363/// Return whether a method declaration belongs to an object literal, not a type.
2364fn is_object_literal_method(node: Node<'_>) -> bool {
2365    node.kind() == "method_definition"
2366        && has_ancestor_kind_any(node.parent(), &["object", "object_pattern", "pair"])
2367}
2368
2369/// Parent object metadata for a JavaScript object-literal method.
2370#[derive(Clone, Debug, Eq, PartialEq)]
2371struct ObjectLiteralMethodOwner {
2372    /// Object or export-assignment name that owns the method.
2373    name: String,
2374    /// Whether the owning object is part of the module API.
2375    exported: bool,
2376}
2377
2378/// Return the owner of an object-literal method when it is useful to index.
2379fn object_literal_method_owner(
2380    method_node: Node<'_>,
2381    content: &str,
2382) -> Option<ObjectLiteralMethodOwner> {
2383    if !is_object_literal_method(method_node) {
2384        return None;
2385    }
2386    let object = nearest_ancestor_kind(method_node.parent(), "object")?;
2387    object_literal_owner(object, content)
2388}
2389
2390/// Return the declaration or assignment that owns an object literal.
2391fn object_literal_owner(object: Node<'_>, content: &str) -> Option<ObjectLiteralMethodOwner> {
2392    let parent = object.parent()?;
2393    match parent.kind() {
2394        "variable_declarator" | "variable_declaration" => {
2395            let name = declarator_name(parent, content)?;
2396            Some(ObjectLiteralMethodOwner {
2397                name,
2398                exported: is_directly_exported_declaration(parent),
2399            })
2400        }
2401        "assignment_expression" | "augmented_assignment_expression" => {
2402            let target = parent
2403                .child_by_field_name("left")
2404                .or_else(|| first_named_child(parent))?;
2405            let name = compact_text(node_text(target, content).as_deref().unwrap_or(""));
2406            if name.is_empty() {
2407                return None;
2408            }
2409            let exported = name == "module.exports"
2410                || name.starts_with("module.exports.")
2411                || name == "exports"
2412                || name.starts_with("exports.");
2413            Some(ObjectLiteralMethodOwner { name, exported })
2414        }
2415        "export_statement" => Some(ObjectLiteralMethodOwner {
2416            name: "default".to_string(),
2417            exported: true,
2418        }),
2419        "pair" => {
2420            let property = parent
2421                .child_by_field_name("key")
2422                .and_then(|key| named_text(key, content))
2423                .unwrap_or_else(|| "object".to_string());
2424            let outer = nearest_ancestor_kind(parent.parent(), "object")
2425                .and_then(|outer| object_literal_owner(outer, content));
2426            outer.map(|owner| ObjectLiteralMethodOwner {
2427                name: format!("{}.{}", owner.name, property),
2428                exported: owner.exported,
2429            })
2430        }
2431        _ => None,
2432    }
2433}
2434
2435/// Return whether a declaration statement is directly wrapped in an export.
2436fn is_directly_exported_declaration(node: Node<'_>) -> bool {
2437    let mut current = Some(node);
2438    while let Some(candidate) = current {
2439        if has_direct_export_parent(candidate) {
2440            return true;
2441        }
2442        if matches!(
2443            candidate.kind(),
2444            "lexical_declaration" | "variable_declaration" | "variable_statement"
2445        ) {
2446            return false;
2447        }
2448        current = candidate.parent();
2449    }
2450    false
2451}
2452
2453/// Return whether a node has an ancestor of the given tree-sitter kind.
2454fn has_ancestor_kind(mut node: Option<Node<'_>>, kind: &str) -> bool {
2455    while let Some(current) = node {
2456        if current.kind() == kind {
2457            return true;
2458        }
2459        node = current.parent();
2460    }
2461    false
2462}
2463
2464/// Return whether a node has any ancestor with one of the given tree-sitter kinds.
2465fn has_ancestor_kind_any(mut node: Option<Node<'_>>, kinds: &[&str]) -> bool {
2466    while let Some(current) = node {
2467        if kinds.contains(&current.kind()) {
2468            return true;
2469        }
2470        node = current.parent();
2471    }
2472    false
2473}
2474
2475/// Return whether a PHP node belongs to an unsupported anonymous class.
2476fn is_inside_php_anonymous_class(node: Node<'_>) -> bool {
2477    has_ancestor_kind(node.parent(), "anonymous_class")
2478}
2479
2480/// Return the nearest ancestor with the requested tree-sitter kind.
2481fn nearest_ancestor_kind<'tree>(mut node: Option<Node<'tree>>, kind: &str) -> Option<Node<'tree>> {
2482    while let Some(current) = node {
2483        if current.kind() == kind {
2484            return Some(current);
2485        }
2486        node = current.parent();
2487    }
2488    None
2489}
2490
2491/// Push a declaration symbol from a tree-sitter node.
2492fn push_tree_symbol(
2493    graph: &mut SymbolGraph,
2494    node: Node<'_>,
2495    content: &str,
2496    symbol_kind: SymbolKind,
2497    php_namespace_context: Option<&mut PhpNamespaceContext>,
2498) -> bool {
2499    let Some(name) = node_name(node, content) else {
2500        return false;
2501    };
2502    let signature = declaration_signature(node, content);
2503    let parent = symbol_parent(node, content, php_namespace_context)
2504        .and_then(|parent| compact_symbol_identity(&parent));
2505    let exported = has_direct_export_parent(node)
2506        || object_literal_method_owner(node, content).is_some_and(|owner| owner.exported)
2507        || is_exported_symbol(graph.language.as_deref(), node, content, &name, &signature);
2508    let documentation = symbol_documentation(node, content);
2509    // Each PHP element shares its declaration header but ends at its own value.
2510    let span_start = if is_php_language(graph.language.as_deref())
2511        && matches!(node.kind(), "property_element" | "const_element")
2512    {
2513        node.parent().unwrap_or(node)
2514    } else {
2515        node
2516    };
2517    let admitted = push_symbol_with_metadata(
2518        graph,
2519        &name,
2520        symbol_kind,
2521        span_start.start_position().row + 1,
2522        node.end_position().row + 1,
2523        parent.clone(),
2524        Some(node.kind()),
2525        &signature,
2526        exported,
2527        documentation.as_deref(),
2528    );
2529    if admitted
2530        && is_php_language(graph.language.as_deref())
2531        && let Some(symbol) = graph.symbols.last_mut()
2532    {
2533        symbol.source_selector = Some(tree_source_selector(span_start, node));
2534    }
2535    if admitted && let Some(parent_name) = parent {
2536        push_relation(
2537            graph,
2538            &parent_name,
2539            &name,
2540            RelationKind::Contains,
2541            span_start.start_position().row + 1,
2542            node.kind(),
2543        );
2544    }
2545    admitted
2546}
2547
2548/// Retain byte offsets and columns until normalization against the original source.
2549fn tree_source_selector(start: Node<'_>, end: Node<'_>) -> SymbolSourceSelector {
2550    SymbolSourceSelector {
2551        byte_start: start.start_byte(),
2552        byte_end: end.end_byte(),
2553        column_start: start.start_position().column,
2554        column_end: end.end_position().column,
2555    }
2556}
2557
2558/// Convert existing selectors to scalar columns in one bounded source traversal.
2559fn normalize_source_selector_columns<E>(
2560    graph: &mut SymbolGraph,
2561    source: &str,
2562    check: &mut impl FnMut() -> Result<(), E>,
2563) -> Result<(), E> {
2564    let mut endpoints = Vec::new();
2565    for (index, symbol) in graph.symbols.iter_mut().enumerate() {
2566        check_parser_iteration(index, check)?;
2567        if let Some(selector) = symbol.source_selector.as_mut() {
2568            endpoints.push((selector.byte_start, &mut selector.column_start));
2569            endpoints.push((selector.byte_end, &mut selector.column_end));
2570        }
2571    }
2572    if endpoints.is_empty() {
2573        return Ok(());
2574    }
2575    let mut ascii = true;
2576    for (index, chunk) in source
2577        .as_bytes()
2578        .chunks(PARSER_CONTROL_CHECK_INTERVAL)
2579        .enumerate()
2580    {
2581        check_parser_iteration(index, check)?;
2582        if !chunk.is_ascii() {
2583            ascii = false;
2584            break;
2585        }
2586    }
2587    if ascii {
2588        return Ok(());
2589    }
2590    endpoints.sort_unstable_by_key(|(offset, _)| *offset);
2591    let mut characters = source.char_indices().peekable();
2592    let mut column = 0;
2593    let mut consumed = 0;
2594    for (offset, target) in endpoints {
2595        check()?;
2596        while characters.peek().is_some_and(|(index, _)| *index < offset) {
2597            check_parser_iteration(consumed, check)?;
2598            let Some((_, character)) = characters.next() else {
2599                break;
2600            };
2601            column = if character == '\n' { 0 } else { column + 1 };
2602            consumed += 1;
2603        }
2604        *target = column;
2605    }
2606    Ok(())
2607}
2608
2609/// Return whether a declaration is directly wrapped by a JavaScript-like export.
2610fn has_direct_export_parent(node: Node<'_>) -> bool {
2611    node.parent()
2612        .is_some_and(|parent| parent.kind() == "export_statement")
2613}
2614
2615/// Return source content with a leading `ProjectAtlas` `Purpose:` header blanked.
2616fn content_without_leading_purpose_header(content: &str) -> Cow<'_, str> {
2617    let Some(start) = content.find(|character: char| !character.is_whitespace()) else {
2618        return Cow::Borrowed(content);
2619    };
2620    let rest = &content[start..];
2621    if let Some(end) = leading_purpose_block_end(rest) {
2622        return Cow::Owned(blank_prefix_preserving_newlines(content, start + end));
2623    }
2624    if let Some(end) = leading_purpose_line_end(rest) {
2625        return Cow::Owned(blank_prefix_preserving_newlines(content, start + end));
2626    }
2627    Cow::Borrowed(content)
2628}
2629
2630/// Return the byte end of a leading block comment when it is a purpose header.
2631fn leading_purpose_block_end(rest: &str) -> Option<usize> {
2632    if !(rest.starts_with("/**") || rest.starts_with("/*")) {
2633        return None;
2634    }
2635    let end = rest.find("*/")? + "*/".len();
2636    let documentation = rest[..end]
2637        .lines()
2638        .filter_map(|line| clean_doc_comment_line(line.trim()))
2639        .collect::<Vec<_>>()
2640        .join(" ");
2641    compact_documentation(&documentation)
2642        .is_some_and(|value| value.starts_with("Purpose:"))
2643        .then_some(end)
2644}
2645
2646/// Return the byte end of a leading line comment when it is a purpose header.
2647fn leading_purpose_line_end(rest: &str) -> Option<usize> {
2648    let line_end = rest.find('\n').map_or(rest.len(), |index| index + 1);
2649    let line = rest[..line_end].trim();
2650    let cleaned = line
2651        .strip_prefix("//")
2652        .or_else(|| line.strip_prefix('#'))
2653        .or_else(|| {
2654            line.strip_prefix("<!--")
2655                .and_then(|value| value.strip_suffix("-->"))
2656        })?
2657        .trim();
2658    cleaned.starts_with("Purpose:").then_some(line_end)
2659}
2660
2661/// Blank a source prefix without changing byte offsets or line numbers.
2662fn blank_prefix_preserving_newlines(content: &str, end: usize) -> String {
2663    let mut output = String::with_capacity(content.len());
2664    debug_assert!(content.is_char_boundary(end));
2665    for byte in &content.as_bytes()[..end] {
2666        output.push(if matches!(*byte, b'\n' | b'\r') {
2667            char::from(*byte)
2668        } else {
2669            ' '
2670        });
2671    }
2672    output.push_str(&content[end..]);
2673    output
2674}
2675
2676/// Return the semantic parent for a declaration symbol.
2677fn symbol_parent(
2678    node: Node<'_>,
2679    content: &str,
2680    php_namespace_context: Option<&mut PhpNamespaceContext>,
2681) -> Option<String> {
2682    if is_inside_php_anonymous_class(node) {
2683        return None;
2684    }
2685    if php_namespace_context.is_some()
2686        && matches!(
2687            node.kind(),
2688            "function_definition"
2689                | "class_declaration"
2690                | "interface_declaration"
2691                | "trait_declaration"
2692                | "enum_declaration"
2693        )
2694    {
2695        // Named PHP declarations belong to their namespace even inside a callable.
2696        return if let Some(namespace) = nearest_ancestor_kind(node.parent(), "namespace_definition")
2697        {
2698            php_bounded_namespace_name(namespace, content)
2699        } else {
2700            php_semicolon_namespace_parent(node, php_namespace_context)
2701        };
2702    }
2703    if let Some(owner) = object_literal_method_owner(node, content) {
2704        return Some(owner.name);
2705    }
2706    if node.kind() == "property_promotion_parameter"
2707        && let Some(class) = nearest_ancestor_kind(node.parent(), "class_declaration")
2708            .or_else(|| nearest_ancestor_kind(node.parent(), "trait_declaration"))
2709    {
2710        return node_name(class, content);
2711    }
2712    if node.kind() == "function_item"
2713        && let Some(impl_node) = nearest_ancestor_kind(node.parent(), "impl_item")
2714    {
2715        return impl_type_name(impl_node, content);
2716    }
2717    if matches!(node.kind(), "function_declarator" | "method_declarator")
2718        && let Some(type_node) = nearest_ancestor_kind(node.parent(), "class_specifier")
2719            .or_else(|| nearest_ancestor_kind(node.parent(), "struct_specifier"))
2720    {
2721        return node_name(type_node, content);
2722    }
2723    let parent = if matches!(node.kind(), "property_element" | "const_element") {
2724        node.parent().and_then(|declaration| declaration.parent())
2725    } else {
2726        node.parent()
2727    };
2728    enclosing_symbol_name(parent, content)
2729        .or_else(|| php_semicolon_namespace_parent(node, php_namespace_context))
2730}
2731
2732/// Return the active PHP namespace for a declaration in a semicolon namespace.
2733fn php_semicolon_namespace_parent(
2734    node: Node<'_>,
2735    php_namespace_context: Option<&mut PhpNamespaceContext>,
2736) -> Option<String> {
2737    if node.kind() == "namespace_definition" {
2738        return None;
2739    }
2740    php_namespace_context.and_then(|context| context.parent_for(node))
2741}
2742
2743/// Map tree-sitter node kinds to `ProjectAtlas` symbol kinds.
2744/// Share actual native declaration admission with source-evidence consumers.
2745fn source_declaration_kind(kind: &str, language: Option<&str>) -> Option<SymbolKind> {
2746    declaration_kind(kind).or_else(|| {
2747        (kind == "variable_declaration"
2748            && matches!(
2749                language.and_then(tree_sitter_grammar),
2750                Some(
2751                    TreeSitterGrammar::JavaScript
2752                        | TreeSitterGrammar::TypeScript
2753                        | TreeSitterGrammar::Tsx
2754                )
2755            ))
2756        .then_some(SymbolKind::Value)
2757    })
2758}
2759
2760/// Map grammar-owned declaration nodes to their raw graph kind.
2761fn declaration_kind(kind: &str) -> Option<SymbolKind> {
2762    match kind {
2763        "function_item"
2764        | "function_declaration"
2765        | "generator_function_declaration"
2766        | "function_definition"
2767        | "function_declarator"
2768        | "func_literal"
2769        | "local_function_statement" => Some(SymbolKind::Function),
2770        "method_definition"
2771        | "method_declarator"
2772        | "method_declaration"
2773        | "function_declaration_with_receiver"
2774        | "constructor_declaration"
2775        | "init_declaration" => Some(SymbolKind::Method),
2776        "class_declaration"
2777        | "class_definition"
2778        | "class_specifier"
2779        | "record_declaration"
2780        | "class_interface"
2781        | "class_implementation" => Some(SymbolKind::Class),
2782        "struct_item" | "struct_specifier" | "union_specifier" | "struct_declaration" => {
2783            Some(SymbolKind::Struct)
2784        }
2785        "enum_item" | "enum_declaration" | "enum_specifier" => Some(SymbolKind::Enum),
2786        "trait_item" | "trait_declaration" => Some(SymbolKind::Trait),
2787        "interface_declaration" | "interface_type" => Some(SymbolKind::Interface),
2788        "mod_item"
2789        | "module_declaration"
2790        | "namespace_declaration"
2791        | "namespace_definition"
2792        | "file_scoped_namespace_declaration"
2793        | "package_declaration"
2794        | "package_clause"
2795        | "package_header" => Some(SymbolKind::Module),
2796        "type_item" | "type_alias_declaration" | "type_declaration" => Some(SymbolKind::Type),
2797        "const_item"
2798        | "static_item"
2799        | "const_declaration"
2800        | "field_declaration"
2801        | "lexical_declaration"
2802        | "var_declaration"
2803        | "short_var_declaration"
2804        | "property_declaration"
2805        | "property_element"
2806        | "property_promotion_parameter"
2807        | "const_element"
2808        | "enum_case" => Some(SymbolKind::Value),
2809        "use_declaration"
2810        | "import_statement"
2811        | "import_declaration"
2812        | "import_from_statement"
2813        | "using_directive"
2814        | "preproc_include"
2815        | "namespace_use_declaration" => Some(SymbolKind::Import),
2816        _ => None,
2817    }
2818}
2819
2820/// Select the exact syntax nodes allowed to produce import occurrence evidence.
2821fn owns_import_occurrence(node: Node<'_>, graph: &SymbolGraph) -> bool {
2822    is_import_node(node.kind())
2823        || (node.kind() == "export_statement"
2824            && semantic::provider_for_graph(graph) == Some(SemanticProviderOwner::EcmaScript)
2825            && node.child_by_field_name("source").is_some())
2826}
2827
2828/// Return whether a node is an import-like relation.
2829fn is_import_node(kind: &str) -> bool {
2830    matches!(
2831        kind,
2832        "use_declaration"
2833            | "import_statement"
2834            | "import_declaration"
2835            | "import_from_statement"
2836            | "using_directive"
2837            | "preproc_include"
2838            | "namespace_use_declaration"
2839            | "include_expression"
2840            | "include_once_expression"
2841            | "require_expression"
2842            | "require_once_expression"
2843    )
2844}
2845
2846/// Return whether a node is a call-like relation.
2847fn is_call_node(kind: &str) -> bool {
2848    matches!(
2849        kind,
2850        "call_expression"
2851            | "method_invocation"
2852            | "invocation_expression"
2853            | "call"
2854            | "macro_invocation"
2855            | "function_call_expression"
2856            | "member_call_expression"
2857            | "nullsafe_member_call_expression"
2858            | "scoped_call_expression"
2859    )
2860}
2861
2862/// Return whether a node is one of PHP's include/require expressions.
2863fn is_php_include_node(kind: &str) -> bool {
2864    matches!(
2865        kind,
2866        "include_expression"
2867            | "include_once_expression"
2868            | "require_expression"
2869            | "require_once_expression"
2870    )
2871}
2872
2873/// Return whether a PHP `use` declaration composes traits inside a type.
2874fn is_php_trait_use_declaration(node: Node<'_>) -> bool {
2875    node.kind() == "use_declaration"
2876        && has_ancestor_kind_any(
2877            node.parent(),
2878            &[
2879                "anonymous_class",
2880                "class_declaration",
2881                "trait_declaration",
2882                "enum_declaration",
2883            ],
2884        )
2885}
2886
2887/// Return the owning type for a PHP trait composition declaration.
2888fn php_trait_use_owner(node: Node<'_>, content: &str) -> Option<String> {
2889    if !is_php_trait_use_declaration(node) {
2890        return None;
2891    }
2892    let mut current = node.parent();
2893    while let Some(candidate) = current {
2894        if declaration_kind(candidate.kind()).is_some() {
2895            return matches!(
2896                candidate.kind(),
2897                "class_declaration" | "trait_declaration" | "enum_declaration"
2898            )
2899            .then(|| node_name(candidate, content))
2900            .flatten();
2901        }
2902        current = candidate.parent();
2903    }
2904    None
2905}
2906
2907/// Return direct trait targets, excluding alias and adaptation clause names.
2908fn php_trait_use_targets(node: Node<'_>, content: &str) -> (Vec<String>, bool) {
2909    let mut targets = Vec::new();
2910    let mut incomplete = false;
2911    let mut cursor = node.walk();
2912    for child in node.named_children(&mut cursor) {
2913        if targets.len() >= MAX_RELATIONS_PER_FILE {
2914            return (targets, true);
2915        }
2916        if matches!(child.kind(), "name" | "qualified_name" | "relative_name") {
2917            if let Some(target) = named_text(child, content)
2918                && target.chars().count() <= MAX_SNIPPET_CHARS
2919            {
2920                targets.push(target);
2921            } else {
2922                incomplete = true;
2923            }
2924        }
2925    }
2926    (targets, incomplete)
2927}
2928
2929/// Publish exact trait-composition targets under their owning PHP type.
2930fn push_php_trait_use_relations(graph: &mut SymbolGraph, node: Node<'_>, content: &str) {
2931    if is_inside_php_anonymous_class(node) {
2932        return;
2933    }
2934    let Some(owner) = php_trait_use_owner(node, content) else {
2935        return;
2936    };
2937    let (targets, incomplete) = php_trait_use_targets(node, content);
2938    if incomplete {
2939        graph.parser = ParserKind::Fallback;
2940    }
2941    for target in targets {
2942        if graph.relations.len() >= MAX_RELATIONS_PER_FILE {
2943            graph.parser = ParserKind::Fallback;
2944            break;
2945        }
2946        push_relation(
2947            graph,
2948            &owner,
2949            &target,
2950            RelationKind::Imports,
2951            node.start_position().row + 1,
2952            &target,
2953        );
2954    }
2955}
2956
2957/// Return whether a supplied language identifier selects the PHP owner.
2958fn is_php_language(language: Option<&str>) -> bool {
2959    language.is_some_and(|language| language.eq_ignore_ascii_case("php"))
2960}
2961
2962/// Return whether a PHP node's facts require conservative partial coverage.
2963fn php_node_is_incomplete(node: Node<'_>, content: &str) -> bool {
2964    if node.kind() == "anonymous_class" {
2965        return true;
2966    }
2967    if node.kind() == "namespace_use_declaration" {
2968        return php_namespace_use_targets(node, content).1;
2969    }
2970    if is_call_node(node.kind()) {
2971        if is_php_first_class_callable_acquisition(node) {
2972            return false;
2973        }
2974        return php_call_target(node, content).is_none();
2975    }
2976    is_php_include_node(node.kind()) && php_static_include_target(node, content).is_none()
2977}
2978
2979/// Keep recovered PHP facts while exposing their conservative parser tier.
2980fn mark_graph_fallback(graph: &mut SymbolGraph) {
2981    graph.parser = ParserKind::Fallback;
2982    for symbol in &mut graph.symbols {
2983        symbol.parser = ParserKind::Fallback;
2984    }
2985    for relation in &mut graph.relations {
2986        relation.parser = ParserKind::Fallback;
2987    }
2988}
2989
2990/// Return a static PHP include target, omitting dynamic or ambiguous expressions.
2991fn php_static_include_target(node: Node<'_>, content: &str) -> Option<String> {
2992    let mut cursor = node.walk();
2993    let mut expression = node
2994        .named_children(&mut cursor)
2995        .find(|child| child.kind() != "comment")?;
2996    if expression.kind() == "parenthesized_expression" {
2997        let mut cursor = expression.walk();
2998        let mut children = expression
2999            .named_children(&mut cursor)
3000            .filter(|child| child.kind() != "comment");
3001        let inner = children.next()?;
3002        if children.next().is_some() {
3003            return None;
3004        }
3005        expression = inner;
3006    }
3007    let target = match expression.kind() {
3008        "string" | "encapsed_string" => php_static_string_target(expression, content)?,
3009        "nowdoc" => php_static_nowdoc_target(expression, content)?,
3010        _ => return None,
3011    };
3012    Some(target).filter(|target| {
3013        !target.is_empty()
3014            && target.chars().count() <= MAX_SNIPPET_CHARS
3015            && GraphIdentityText::new(target.clone()).is_ok()
3016    })
3017}
3018
3019/// Return the exact non-interpolating value of a PHP nowdoc literal.
3020fn php_static_nowdoc_target(node: Node<'_>, content: &str) -> Option<String> {
3021    let value = node.child_by_field_name("value")?;
3022    let value = node_text(value, content)?;
3023    let value = value
3024        .strip_prefix("\r\n")
3025        .or_else(|| value.strip_prefix('\n'))
3026        .or_else(|| value.strip_prefix('\r'))?;
3027    let end_tag = node.child_by_field_name("end_tag")?;
3028    let line_start = content[..end_tag.start_byte()]
3029        .rfind(['\n', '\r'])
3030        .map_or(0, |newline| newline + 1);
3031    let indentation = content.get(line_start..end_tag.start_byte())?;
3032    if !indentation
3033        .as_bytes()
3034        .iter()
3035        .all(|byte| matches!(*byte, b' ' | b'\t'))
3036    {
3037        return None;
3038    }
3039    let mut target = String::with_capacity(value.len());
3040    for line in value.split_inclusive('\n') {
3041        let (line, newline) = line
3042            .strip_suffix('\n')
3043            .map_or((line, ""), |line| (line, "\n"));
3044        let line = line.strip_suffix('\r').unwrap_or(line);
3045        let blank = line.bytes().all(|byte| matches!(byte, b' ' | b'\t'));
3046        if !indentation.is_empty() && !blank && !line.starts_with(indentation) {
3047            return None;
3048        }
3049        let line = line.strip_prefix(indentation).unwrap_or(line);
3050        target.push_str(line);
3051        target.push_str(newline);
3052    }
3053    Some(target)
3054}
3055
3056/// Return the plain content of a PHP string literal when it has no interpolation.
3057fn php_static_string_target(node: Node<'_>, content: &str) -> Option<String> {
3058    let mut cursor = node.walk();
3059    let mut target = String::new();
3060    let mut has_part = false;
3061    for child in node.named_children(&mut cursor) {
3062        match child.kind() {
3063            "string_content" => target.push_str(&node_text(child, content)?),
3064            "escape_sequence" if node.kind() == "string" => {
3065                match node_text(child, content)?.as_str() {
3066                    r"\\" => target.push('\\'),
3067                    r"\'" => target.push('\''),
3068                    _ => return None,
3069                }
3070            }
3071            "escape_sequence" if node.kind() == "encapsed_string" => {
3072                target.push_str(php_double_quoted_escape_target(child, content)?);
3073            }
3074            _ => return None,
3075        }
3076        has_part = true;
3077    }
3078    has_part.then_some(target)
3079}
3080
3081/// Decode one grammar-recognized, non-interpolating PHP double-quoted escape.
3082fn php_double_quoted_escape_target(node: Node<'_>, content: &str) -> Option<&'static str> {
3083    match node_text(node, content)?.as_str() {
3084        r"\\" => Some("\\"),
3085        r#"\""# => Some("\""),
3086        r"\n" => Some("\n"),
3087        r"\r" => Some("\r"),
3088        r"\t" => Some("\t"),
3089        r"\v" => Some("\x0b"),
3090        r"\e" => Some("\x1b"),
3091        r"\f" => Some("\x0c"),
3092        r"\$" => Some("$"),
3093        r"\`" => Some("`"),
3094        _ => None,
3095    }
3096}
3097
3098/// Return bounded PHP namespace-use targets and whether any target was omitted.
3099fn php_namespace_use_targets(node: Node<'_>, content: &str) -> (Vec<String>, bool) {
3100    let mut targets = Vec::new();
3101    let mut incomplete = false;
3102    let mut prefix = None;
3103    let mut cursor = node.walk();
3104    for child in node.named_children(&mut cursor) {
3105        if targets.len() >= MAX_RELATIONS_PER_FILE {
3106            incomplete = true;
3107            break;
3108        }
3109        match child.kind() {
3110            "namespace_use_clause" => {
3111                if let Some(target) = php_namespace_use_clause_target(child, content, None) {
3112                    targets.push(target);
3113                } else {
3114                    incomplete = true;
3115                }
3116            }
3117            "namespace_name" => {
3118                prefix = php_bounded_name_text(child, content);
3119                if prefix.is_none() {
3120                    return (targets, true);
3121                }
3122            }
3123            "namespace_use_group" => {
3124                incomplete |= php_namespace_use_group_targets(
3125                    child,
3126                    content,
3127                    prefix.as_deref(),
3128                    &mut targets,
3129                );
3130            }
3131            _ => {}
3132        }
3133    }
3134    (targets, incomplete)
3135}
3136
3137/// Collect grouped PHP namespace-use targets, reporting omitted clauses.
3138fn php_namespace_use_group_targets(
3139    node: Node<'_>,
3140    content: &str,
3141    prefix: Option<&str>,
3142    targets: &mut Vec<String>,
3143) -> bool {
3144    let mut incomplete = false;
3145    let mut cursor = node.walk();
3146    for child in node.named_children(&mut cursor) {
3147        if targets.len() >= MAX_RELATIONS_PER_FILE {
3148            incomplete = true;
3149            break;
3150        }
3151        if child.kind() == "namespace_use_clause" {
3152            if let Some(target) = php_namespace_use_clause_target(child, content, prefix) {
3153                targets.push(target);
3154            } else {
3155                incomplete = true;
3156            }
3157        }
3158    }
3159    incomplete
3160}
3161
3162/// Compose one PHP namespace-use clause with an optional grouped prefix.
3163fn php_namespace_use_clause_target(
3164    node: Node<'_>,
3165    content: &str,
3166    prefix: Option<&str>,
3167) -> Option<String> {
3168    let target = first_named_child(node).and_then(|child| {
3169        matches!(child.kind(), "name" | "qualified_name" | "relative_name")
3170            .then(|| php_bounded_name_text(child, content))
3171            .flatten()
3172    })?;
3173    let target = match prefix {
3174        Some(prefix) if !prefix.is_empty() => {
3175            if prefix.chars().take(MAX_SNIPPET_CHARS + 1).count() + 1 + target.chars().count()
3176                > MAX_SNIPPET_CHARS
3177            {
3178                return None;
3179            }
3180            format!("{prefix}\\{target}")
3181        }
3182        _ => target,
3183    };
3184    Some(target)
3185}
3186
3187/// Retain a proven local binding when an import's complete target is overbound.
3188fn php_namespace_use_target(node: Node<'_>, content: &str) -> Option<String> {
3189    php_namespace_use_targets(node, content)
3190        .0
3191        .into_iter()
3192        .next()
3193        .or_else(|| php_namespace_use_binding(node, content))
3194}
3195
3196/// Select an actual alias or terminal import name without inventing a target.
3197fn php_namespace_use_binding(node: Node<'_>, content: &str) -> Option<String> {
3198    if node.kind() == "namespace_use_clause" {
3199        let name = node.child_by_field_name("alias").or_else(|| {
3200            let target = first_named_child(node)?;
3201            if target.kind() == "name" {
3202                Some(target)
3203            } else {
3204                let mut cursor = target.walk();
3205                target
3206                    .named_children(&mut cursor)
3207                    .find(|child| child.kind() == "name")
3208            }
3209        })?;
3210        return php_bounded_name_text(name, content);
3211    }
3212    let mut cursor = node.walk();
3213    node.named_children(&mut cursor)
3214        .filter(|child| matches!(child.kind(), "namespace_use_clause" | "namespace_use_group"))
3215        .find_map(|child| php_namespace_use_binding(child, content))
3216}
3217
3218/// Return a conservative PHP call target, suppressing dynamic calls.
3219fn php_call_target(node: Node<'_>, content: &str) -> Option<String> {
3220    if node.kind() == "function_call_expression"
3221        && node
3222            .child_by_field_name("function")
3223            .and_then(|function| named_text(function, content))
3224            .is_some_and(|name| name.eq_ignore_ascii_case("eval"))
3225    {
3226        return None;
3227    }
3228    let target = match node.kind() {
3229        "scoped_call_expression" => {
3230            let scope = node.child_by_field_name("scope")?;
3231            let name = node.child_by_field_name("name")?;
3232            let scope = php_static_call_part(scope, content)?;
3233            let name = php_static_call_part(name, content)?;
3234            format!("{scope}::{name}")
3235        }
3236        // The receiver determines member dispatch, and this parser does not
3237        // resolve object types. Publishing only the member name would create
3238        // a false edge to an unrelated same-file function or method.
3239        "member_call_expression" | "nullsafe_member_call_expression" => return None,
3240        _ => php_static_call_part(node.child_by_field_name("function")?, content)?,
3241    };
3242    let target = compact_text(&target);
3243    (!target.is_empty() && target.chars().count() <= MAX_SNIPPET_CHARS).then_some(target)
3244}
3245
3246/// Return a static PHP name-like call component, excluding variables and expressions.
3247fn php_static_call_part(node: Node<'_>, content: &str) -> Option<String> {
3248    if matches!(
3249        node.kind(),
3250        "dynamic_variable_name"
3251            | "variable_name"
3252            | "expression"
3253            | "parenthesized_expression"
3254            | "member_call_expression"
3255            | "nullsafe_member_call_expression"
3256            | "function_call_expression"
3257            | "scoped_call_expression"
3258    ) {
3259        return None;
3260    }
3261    matches!(
3262        node.kind(),
3263        "name" | "qualified_name" | "relative_name" | "relative_scope" | "identifier"
3264    )
3265    .then(|| named_text(node, content))
3266    .flatten()
3267}
3268
3269/// Return whether a PHP call node acquires a callable instead of invoking it.
3270fn is_php_first_class_callable_acquisition(node: Node<'_>) -> bool {
3271    node.child_by_field_name("arguments")
3272        .is_some_and(|arguments| has_direct_child_kind(arguments, "variadic_placeholder"))
3273}
3274
3275/// Return whether a subtree contains any node with one of the given kinds.
3276fn has_descendant_kind(node: Node<'_>, kinds: &[&str]) -> bool {
3277    let mut cursor = node.walk();
3278    for child in node.named_children(&mut cursor) {
3279        if kinds.contains(&child.kind()) || has_descendant_kind(child, kinds) {
3280            return true;
3281        }
3282    }
3283    false
3284}
3285
3286/// Return whether a node has a direct named child of the requested kind.
3287fn has_direct_child_kind(node: Node<'_>, kind: &str) -> bool {
3288    let mut cursor = node.walk();
3289    node.named_children(&mut cursor)
3290        .any(|child| child.kind() == kind)
3291}
3292
3293/// Push an import relation from an import node.
3294fn push_import_relation<E>(
3295    graph: &mut SymbolGraph,
3296    node: Node<'_>,
3297    content: &str,
3298    check: &mut impl FnMut() -> Result<(), E>,
3299) -> Result<Option<rust_use_source::RustUseSources>, E> {
3300    if is_php_language(graph.language.as_deref()) && is_inside_php_anonymous_class(node) {
3301        return Ok(None);
3302    }
3303    if node.kind() == "namespace_use_declaration" {
3304        for import_text in php_namespace_use_targets(node, content).0 {
3305            if graph.relations.len() >= MAX_RELATIONS_PER_FILE {
3306                graph.parser = ParserKind::Fallback;
3307                break;
3308            }
3309            if !import_text.is_empty() && import_text.chars().count() <= MAX_SNIPPET_CHARS {
3310                push_relation(
3311                    graph,
3312                    MODULE_RELATION_SOURCE,
3313                    &import_text,
3314                    RelationKind::Imports,
3315                    node.start_position().row + 1,
3316                    &import_text,
3317                );
3318            }
3319        }
3320        return Ok(None);
3321    }
3322    if semantic::provider_for_graph(graph).is_some_and(semantic::supports_source_dependencies) {
3323        if semantic::provider_for_graph(graph) == Some(SemanticProviderOwner::Rust)
3324            && let Some(sources) = rust_use_source::use_sources(
3325                node,
3326                content,
3327                MAX_RELATIONS_PER_FILE.saturating_sub(graph.relations.len()),
3328                check,
3329            )?
3330        {
3331            let context = node
3332                .utf8_text(content.as_bytes())
3333                .unwrap_or_default()
3334                .chars()
3335                .take(MAX_SNIPPET_CHARS)
3336                .collect::<String>();
3337            if !sources.complete {
3338                graph.parser = ParserKind::Fallback;
3339            }
3340            for fact in &sources.facts {
3341                push_relation_preserving_target(
3342                    graph,
3343                    MODULE_RELATION_SOURCE,
3344                    &fact.statement,
3345                    RelationKind::Imports,
3346                    fact.line,
3347                    &context,
3348                );
3349            }
3350            return Ok(Some(sources));
3351        }
3352        let import_text = if semantic::provider_for_graph(graph)
3353            == Some(SemanticProviderOwner::EcmaScript)
3354            && node.has_error()
3355        {
3356            import_source::canonical_import_recovery_source(node, content, check)?
3357        } else {
3358            import_source::canonical_import_source(node, content, check)?
3359        };
3360        let Some(import_text) = import_text else {
3361            graph.parser = ParserKind::Fallback;
3362            return Ok(None);
3363        };
3364        let context = node
3365            .utf8_text(content.as_bytes())
3366            .unwrap_or_default()
3367            .chars()
3368            .take(MAX_SNIPPET_CHARS)
3369            .collect::<String>();
3370        push_relation_preserving_target(
3371            graph,
3372            MODULE_RELATION_SOURCE,
3373            &import_text,
3374            RelationKind::Imports,
3375            node.start_position().row + 1,
3376            &context,
3377        );
3378        return Ok(None);
3379    }
3380    let import_text = if is_php_include_node(node.kind()) {
3381        php_static_include_target(node, content)
3382    } else {
3383        Some(compact_text(
3384            node_text(node, content).as_deref().unwrap_or(""),
3385        ))
3386    };
3387    let Some(import_text) = import_text else {
3388        return Ok(None);
3389    };
3390    if import_text.is_empty() || import_text.chars().count() > MAX_SNIPPET_CHARS {
3391        return Ok(None);
3392    }
3393    if is_php_include_node(node.kind()) {
3394        // Keep bounded source syntax so partial graphs distinguish includes from aliases.
3395        let context = node
3396            .utf8_text(content.as_bytes())
3397            .unwrap_or_default()
3398            .chars()
3399            .take(MAX_SNIPPET_CHARS)
3400            .collect::<String>();
3401        push_relation_preserving_target(
3402            graph,
3403            MODULE_RELATION_SOURCE,
3404            &import_text,
3405            RelationKind::Imports,
3406            node.start_position().row + 1,
3407            &compact_text(&context),
3408        );
3409    } else {
3410        push_relation(
3411            graph,
3412            MODULE_RELATION_SOURCE,
3413            &import_text,
3414            RelationKind::Imports,
3415            node.start_position().row + 1,
3416            &import_text,
3417        );
3418    }
3419    Ok(None)
3420}
3421
3422/// Push a call relation from a call node.
3423fn push_call_relation(
3424    graph: &mut SymbolGraph,
3425    node: Node<'_>,
3426    content: &str,
3427    php_namespace_context: Option<&mut PhpNamespaceContext>,
3428) {
3429    if is_php_language(graph.language.as_deref()) && is_inside_php_anonymous_class(node) {
3430        return;
3431    }
3432    if is_php_language(graph.language.as_deref()) && is_php_first_class_callable_acquisition(node) {
3433        return;
3434    }
3435    let target_node = node
3436        .child_by_field_name("function")
3437        .or_else(|| first_named_child(node));
3438    let Some(target_node) = target_node else {
3439        return;
3440    };
3441    let is_php = is_php_language(graph.language.as_deref());
3442    let target = if is_php {
3443        let Some(target) = php_call_target(node, content) else {
3444            return;
3445        };
3446        target
3447    } else if node.kind() == "method_invocation"
3448        && graph.language.as_deref().and_then(tree_sitter_grammar) == Some(TreeSitterGrammar::Java)
3449    {
3450        let Some(name) = node
3451            .child_by_field_name("name")
3452            .and_then(|name| node_text(name, content))
3453        else {
3454            return;
3455        };
3456        node.child_by_field_name("object")
3457            .and_then(|object| node_text(object, content))
3458            .map_or_else(
3459                || compact_text(&name),
3460                |object| compact_text(&format!("{object}.{name}")),
3461            )
3462    } else {
3463        compact_text(node_text(target_node, content).as_deref().unwrap_or(""))
3464    };
3465    if target.is_empty() || (!is_php && target.len() > MAX_SNIPPET_CHARS) {
3466        return;
3467    }
3468    let source = enclosing_symbol_name(node.parent(), content)
3469        .or_else(|| {
3470            is_php
3471                .then(|| php_namespace_context.and_then(|context| context.parent_for(node)))
3472                .flatten()
3473        })
3474        .unwrap_or_else(|| MODULE_RELATION_SOURCE.into());
3475    let context = compact_text(node_text(node, content).as_deref().unwrap_or(""));
3476    push_relation(
3477        graph,
3478        &source,
3479        &target,
3480        RelationKind::Calls,
3481        node.start_position().row + 1,
3482        &context,
3483    );
3484    if graph.language.as_deref().and_then(tree_sitter_grammar) == Some(TreeSitterGrammar::Rust)
3485        && rust_target_invokes_function_item(target_node, content)
3486        && let Some(arguments) = node.child_by_field_name("arguments")
3487        && let Some(callback) = first_named_child(arguments)
3488        && callback.kind() == "scoped_identifier"
3489    {
3490        let callback = compact_text(node_text(callback, content).as_deref().unwrap_or(""));
3491        if !callback.is_empty() && callback.len() <= MAX_SNIPPET_CHARS {
3492            push_relation(
3493                graph,
3494                &source,
3495                &callback,
3496                RelationKind::Calls,
3497                node.start_position().row + 1,
3498                &context,
3499            );
3500        }
3501    }
3502}
3503
3504/// Return whether one Rust method target proves that its function-item argument is invoked.
3505fn rust_target_invokes_function_item(target: Node<'_>, content: &str) -> bool {
3506    if target.kind() != "field_expression"
3507        || target
3508            .child_by_field_name("field")
3509            .and_then(|field| node_text(field, content))
3510            .as_deref()
3511            != Some("then")
3512    {
3513        return false;
3514    }
3515    target
3516        .child_by_field_name("value")
3517        .is_some_and(|receiver| rust_expression_is_definitely_bool(receiver, content))
3518}
3519
3520/// Recognize Rust expressions whose syntax itself guarantees a Boolean value.
3521fn rust_expression_is_definitely_bool(mut expression: Node<'_>, content: &str) -> bool {
3522    while expression.kind() == "parenthesized_expression" {
3523        let Some(inner) = first_named_child(expression) else {
3524            return false;
3525        };
3526        expression = inner;
3527    }
3528    match expression.kind() {
3529        "boolean_literal" => true,
3530        "binary_expression" => {
3531            let (Some(left), Some(right)) = (
3532                expression.child_by_field_name("left"),
3533                expression.child_by_field_name("right"),
3534            ) else {
3535                return false;
3536            };
3537            content
3538                .get(left.end_byte()..right.start_byte())
3539                .is_some_and(|operator| {
3540                    matches!(
3541                        operator.trim(),
3542                        "==" | "!=" | "<" | "<=" | ">" | ">=" | "&&" | "||"
3543                    )
3544                })
3545        }
3546        _ => false,
3547    }
3548}
3549
3550/// Return the first named child of a node.
3551fn first_named_child(node: Node<'_>) -> Option<Node<'_>> {
3552    let mut cursor = node.walk();
3553    node.named_children(&mut cursor).next()
3554}
3555
3556/// Extract a human-readable symbol name from common tree-sitter fields.
3557fn node_name(node: Node<'_>, content: &str) -> Option<String> {
3558    if let Some(name) = declaration_specific_name(node, content) {
3559        return Some(name);
3560    }
3561    if let Some(declarator) = node.child_by_field_name("declarator")
3562        && let Some(name) = declarator_name(declarator, content)
3563    {
3564        return Some(name);
3565    }
3566    for field_name in ["name", "field", "property", "type", "path"] {
3567        if let Some(child) = node.child_by_field_name(field_name)
3568            && let Some(name) = named_text(child, content)
3569        {
3570            return Some(name);
3571        }
3572    }
3573    let mut cursor = node.walk();
3574    for child in node.named_children(&mut cursor) {
3575        if matches!(
3576            child.kind(),
3577            "identifier" | "type_identifier" | "property_identifier" | "field_identifier"
3578        ) && let Some(name) = named_text(child, content)
3579        {
3580            return Some(name);
3581        }
3582    }
3583    None
3584}
3585
3586/// Extract names that need language-specific cleanup from a declaration node.
3587fn declaration_specific_name(node: Node<'_>, content: &str) -> Option<String> {
3588    match node.kind() {
3589        kind if is_import_node(kind) => import_declaration_name(node, content),
3590        "namespace_definition" => node
3591            .child_by_field_name("name")
3592            .and_then(|name| named_text(name, content)),
3593        "property_declaration"
3594        | "property_element"
3595        | "property_promotion_parameter"
3596        | "const_declaration"
3597        | "const_element"
3598        | "enum_case" => php_declaration_name(node, content),
3599        "package_declaration" | "package_clause" | "package_header" => {
3600            prefixed_declaration_name(node, content, &["package"])
3601        }
3602        "namespace_declaration" | "file_scoped_namespace_declaration" => {
3603            prefixed_declaration_name(node, content, &["namespace"])
3604        }
3605        "module_declaration" => {
3606            prefixed_declaration_name(node, content, &["module", "declare module"])
3607        }
3608        "type_declaration" => keyword_identifier_name(node, content, "type"),
3609        "lexical_declaration"
3610        | "field_declaration"
3611        | "variable_declaration"
3612        | "variable_statement"
3613        | "var_declaration" => first_variable_declarator_name(node, content),
3614        _ => None,
3615    }
3616}
3617
3618/// Extract the semantic target of an import-like declaration.
3619fn import_declaration_name(node: Node<'_>, content: &str) -> Option<String> {
3620    if node.kind() == "namespace_use_declaration" {
3621        return php_namespace_use_target(node, content);
3622    }
3623    if is_php_include_node(node.kind()) {
3624        return php_static_include_target(node, content);
3625    }
3626    if node.kind() == "import_spec_list" {
3627        let mut cursor = node.walk();
3628        let mut children = node.named_children(&mut cursor);
3629        let only_child = children.next()?;
3630        if children.next().is_some() {
3631            return None;
3632        }
3633        return import_declaration_name(only_child, content);
3634    }
3635    for field_name in ["argument", "source", "module_name", "path", "name"] {
3636        if let Some(target) = node.child_by_field_name(field_name)
3637            && let Some(name) = named_text(target, content)
3638        {
3639            return Some(name);
3640        }
3641    }
3642    let mut cursor = node.walk();
3643    for child in node.named_children(&mut cursor) {
3644        if matches!(child.kind(), "import_spec" | "import_spec_list")
3645            && let Some(name) = import_declaration_name(child, content)
3646        {
3647            return Some(name);
3648        }
3649        if matches!(
3650            child.kind(),
3651            "identifier"
3652                | "scoped_identifier"
3653                | "dotted_name"
3654                | "string"
3655                | "string_literal"
3656                | "system_lib_string"
3657                | "type"
3658        ) && let Some(name) = named_text(child, content)
3659        {
3660            return Some(name);
3661        }
3662    }
3663    None
3664}
3665
3666/// Extract a PHP property, constant, or enum-case name without initializer text.
3667fn php_declaration_name(node: Node<'_>, content: &str) -> Option<String> {
3668    if let Some(name) = node.child_by_field_name("name")
3669        && let Some(name) = named_text(name, content)
3670    {
3671        return Some(name.trim_start_matches('$').to_string());
3672    }
3673    let mut cursor = node.walk();
3674    for child in node.named_children(&mut cursor) {
3675        if matches!(child.kind(), "name" | "variable_name")
3676            && let Some(name) = named_text(child, content)
3677        {
3678            return Some(name.trim_start_matches('$').to_string());
3679        }
3680        if matches!(child.kind(), "property_element" | "const_element")
3681            && let Some(name) = php_declaration_name(child, content)
3682        {
3683            return Some(name);
3684        }
3685    }
3686    None
3687}
3688
3689/// Extract a declaration name by removing a language keyword prefix.
3690fn prefixed_declaration_name(node: Node<'_>, content: &str, prefixes: &[&str]) -> Option<String> {
3691    if let Some(name) = node.child_by_field_name("name") {
3692        return (!name.is_missing() && !name.has_error())
3693            .then(|| named_text(name, content))
3694            .flatten()
3695            .map(|name| name.trim_matches('"').to_string())
3696            .filter(|name| !name.is_empty());
3697    }
3698    if matches!(
3699        node.kind(),
3700        "namespace_declaration" | "file_scoped_namespace_declaration" | "module_declaration"
3701    ) {
3702        return None;
3703    }
3704    let text = compact_text(&node_text(node, content)?);
3705    for prefix in prefixes {
3706        let Some(rest) = text.strip_prefix(prefix) else {
3707            continue;
3708        };
3709        let name = rest
3710            .trim()
3711            .trim_matches('"')
3712            .trim_end_matches(';')
3713            .trim_end_matches('{')
3714            .trim()
3715            .to_string();
3716        if !name.is_empty() {
3717            return Some(name);
3718        }
3719    }
3720    None
3721}
3722
3723/// Extract the first identifier after a declaration keyword.
3724fn keyword_identifier_name(node: Node<'_>, content: &str, keyword: &str) -> Option<String> {
3725    let text = compact_text(&node_text(node, content)?);
3726    let rest = text.strip_prefix(keyword)?.trim();
3727    rest.split_whitespace()
3728        .next()
3729        .map(|name| name.trim_matches(';').to_string())
3730        .filter(|name| !name.is_empty())
3731}
3732
3733/// Extract the implemented Rust type name from an `impl` block.
3734fn impl_type_name(node: Node<'_>, content: &str) -> Option<String> {
3735    if let Some(type_node) = node.child_by_field_name("type")
3736        && let Some(name) = named_text(type_node, content)
3737    {
3738        return Some(clean_type_name(&name));
3739    }
3740    let mut cursor = node.walk();
3741    for child in node.named_children(&mut cursor) {
3742        if matches!(
3743            child.kind(),
3744            "type_identifier" | "scoped_type_identifier" | "generic_type" | "identifier"
3745        ) && let Some(name) = named_text(child, content)
3746        {
3747            return Some(clean_type_name(&name));
3748        }
3749    }
3750    None
3751}
3752
3753/// Remove Rust type adornments from a parent type name.
3754fn clean_type_name(value: &str) -> String {
3755    value
3756        .trim()
3757        .trim_start_matches('&')
3758        .trim_start_matches("mut ")
3759        .split(['<', ' ', '{'])
3760        .next()
3761        .unwrap_or(value)
3762        .trim()
3763        .to_string()
3764}
3765
3766/// Return the first declared variable name in a declaration statement.
3767fn first_variable_declarator_name(node: Node<'_>, content: &str) -> Option<String> {
3768    let mut cursor = node.walk();
3769    for child in node.named_children(&mut cursor) {
3770        if child.kind() == "variable_declarator"
3771            && child
3772                .child_by_field_name("name")
3773                .is_some_and(|name| matches!(name.kind(), "object_pattern" | "array_pattern"))
3774        {
3775            // A destructuring property's key is not the declared binding name,
3776            // and a callable initializer does not make that property callable.
3777            return None;
3778        }
3779        if matches!(child.kind(), "variable_declarator" | "identifier")
3780            && let Some(name) = declarator_name(child, content)
3781        {
3782            return Some(name);
3783        }
3784    }
3785    let mut cursor = node.walk();
3786    for child in node.named_children(&mut cursor) {
3787        if child.kind() == "variable_declaration"
3788            && let Some(name) = first_variable_declarator_name(child, content)
3789        {
3790            return Some(name);
3791        }
3792    }
3793    None
3794}
3795
3796/// Extract the declared name from a declarator subtree.
3797fn declarator_name(node: Node<'_>, content: &str) -> Option<String> {
3798    if node.kind() == "qualified_identifier" {
3799        // A nested C++ qualifier is an owner path, not the declared leaf name.
3800        let mut name = node;
3801        for _ in 0..MAX_TREE_SITTER_AST_DEPTH {
3802            if name.kind() != "qualified_identifier" {
3803                return declarator_name(name, content);
3804            }
3805            name = name.child_by_field_name("name")?;
3806        }
3807        return None;
3808    }
3809    if let Some(name_node) = node.child_by_field_name("name")
3810        && let Some(name) = named_text(name_node, content)
3811    {
3812        return Some(strip_declarator_noise(&name));
3813    }
3814    if matches!(
3815        node.kind(),
3816        "identifier" | "type_identifier" | "property_identifier" | "field_identifier"
3817    ) && let Some(name) = named_text(node, content)
3818    {
3819        return Some(strip_declarator_noise(&name));
3820    }
3821    let mut cursor = node.walk();
3822    for child in node.named_children(&mut cursor) {
3823        if let Some(name) = declarator_name(child, content) {
3824            return Some(name);
3825        }
3826    }
3827    None
3828}
3829
3830/// Remove initializer or parameter text accidentally captured with a declarator.
3831fn strip_declarator_noise(value: &str) -> String {
3832    value
3833        .split(['=', '(', ':'])
3834        .next()
3835        .unwrap_or(value)
3836        .trim()
3837        .to_string()
3838}
3839
3840/// Return compact text for a likely name node.
3841fn named_text(node: Node<'_>, content: &str) -> Option<String> {
3842    let text = node_text(node, content)?;
3843    let compact = compact_text(&text);
3844    if compact.is_empty() {
3845        None
3846    } else {
3847        Some(compact)
3848    }
3849}
3850
3851/// Build a compact declaration signature for a node.
3852fn declaration_signature(node: Node<'_>, content: &str) -> String {
3853    if matches!(node.kind(), "property_element" | "const_element") {
3854        return php_element_signature(node, content);
3855    }
3856    let header_end = declaration_body_start(node)
3857        .or_else(|| {
3858            // A bodyless method header has the same source signature as its
3859            // implementation. The terminating semicolon is not a signature token.
3860            if node.kind() == "method_declaration" {
3861                node.children(&mut node.walk())
3862                    .find(|child| child.kind() == ";")
3863                    .map(|semicolon| semicolon.start_byte())
3864            } else {
3865                None
3866            }
3867        })
3868        .unwrap_or_else(|| node.end_byte());
3869    let mut signature = String::new();
3870    append_declaration_tokens(node, content, header_end, &mut signature);
3871    if signature.is_empty() {
3872        node_text(node, content).map_or_else(String::new, |raw| compact_text(&raw))
3873    } else {
3874        signature
3875    }
3876}
3877
3878/// Build a PHP property or constant element signature with its declaration header.
3879fn php_element_signature(node: Node<'_>, content: &str) -> String {
3880    let Some(parent) = node.parent() else {
3881        return node_text(node, content).map_or_else(String::new, |raw| compact_text(&raw));
3882    };
3883    let mut cursor = parent.walk();
3884    let first_element_start = parent
3885        .named_children(&mut cursor)
3886        .find(|child| matches!(child.kind(), "property_element" | "const_element"))
3887        .map_or(node.start_byte(), |child| child.start_byte());
3888    let mut signature = String::new();
3889    append_declaration_tokens(parent, content, first_element_start, &mut signature);
3890    let element_end = declaration_body_start(node).unwrap_or_else(|| node.end_byte());
3891    append_declaration_tokens(node, content, element_end, &mut signature);
3892    if signature.is_empty() {
3893        node_text(node, content).map_or_else(String::new, |raw| compact_text(&raw))
3894    } else {
3895        signature
3896    }
3897}
3898
3899/// Return the byte at which executable or member body syntax begins.
3900fn declaration_body_start(node: Node<'_>) -> Option<usize> {
3901    if matches!(
3902        node.kind(),
3903        "property_declaration"
3904            | "property_element"
3905            | "const_declaration"
3906            | "const_element"
3907            | "enum_case"
3908    ) && let Some(initializer) = php_initializer_start(node)
3909    {
3910        return Some(initializer);
3911    }
3912    if declaration_has_direct_callable_initializer(node)
3913        && let Some(initializer) = first_declaration_initializer(node)
3914        && let Some(body) = initializer.child_by_field_name("body")
3915    {
3916        return Some(body.start_byte());
3917    }
3918    if (declaration_kind(node.kind()) == Some(SymbolKind::Value)
3919        || node.kind() == "variable_declaration")
3920        && let Some(initializer) = first_declaration_initializer(node)
3921    {
3922        return Some(initializer.start_byte());
3923    }
3924    if let Some(body) = node.child_by_field_name("body") {
3925        return Some(body.start_byte());
3926    }
3927    let mut cursor = node.walk();
3928    node.named_children(&mut cursor)
3929        .find(|child| {
3930            matches!(
3931                child.kind(),
3932                "block" | "compound_statement" | "statement_block"
3933            ) || child.kind().ends_with("_body")
3934        })
3935        .map(|body| body.start_byte())
3936}
3937
3938/// Return the first byte of a PHP value initializer, if one is present.
3939fn php_initializer_start(node: Node<'_>) -> Option<usize> {
3940    let mut cursor = node.walk();
3941    let mut after_equals = false;
3942    for child in node.children(&mut cursor) {
3943        if after_equals && child.is_named() {
3944            return Some(child.start_byte());
3945        }
3946        after_equals = child.kind() == "=";
3947    }
3948    let mut cursor = node.walk();
3949    node.named_children(&mut cursor)
3950        .find_map(php_initializer_start)
3951}
3952
3953/// Append non-comment leaf tokens before a declaration body in source order.
3954fn append_declaration_tokens(
3955    node: Node<'_>,
3956    content: &str,
3957    header_end: usize,
3958    signature: &mut String,
3959) {
3960    if node.start_byte() >= header_end || node.kind().contains("comment") {
3961        return;
3962    }
3963    if node.child_count() == 0 {
3964        if node.end_byte() <= header_end
3965            && let Ok(token) = node.utf8_text(content.as_bytes())
3966        {
3967            let token = token.trim();
3968            if !token.is_empty() {
3969                if !signature.is_empty() {
3970                    signature.push(' ');
3971                }
3972                signature.push_str(token);
3973            }
3974        }
3975        return;
3976    }
3977    let mut cursor = node.walk();
3978    for child in node.children(&mut cursor) {
3979        append_declaration_tokens(child, content, header_end, signature);
3980    }
3981}
3982
3983/// Return whether a declaration is exported or publicly visible.
3984fn is_exported_symbol(
3985    language: Option<&str>,
3986    node: Node<'_>,
3987    content: &str,
3988    name: &str,
3989    signature: &str,
3990) -> bool {
3991    if is_php_language(language) {
3992        return php_declaration_is_exported(node, content);
3993    }
3994    let trimmed = signature.trim_start();
3995    trimmed.starts_with("pub ")
3996        || trimmed.starts_with("pub(")
3997        || trimmed.starts_with("export ")
3998        || trimmed.starts_with("public ")
3999        || trimmed.starts_with("open ")
4000        || matches!(language, Some("go")) && starts_with_uppercase(name)
4001}
4002
4003/// Return whether a PHP declaration has no private or protected visibility modifier.
4004fn php_declaration_is_exported(node: Node<'_>, content: &str) -> bool {
4005    if is_import_node(node.kind()) {
4006        return false;
4007    }
4008    let declaration = match node.kind() {
4009        "property_element" | "const_element" => node.parent(),
4010        _ => Some(node),
4011    };
4012    let Some(declaration) = declaration else {
4013        return true;
4014    };
4015    let mut cursor = declaration.walk();
4016    !declaration
4017        .named_children(&mut cursor)
4018        .filter(|child| child.kind() == "visibility_modifier")
4019        .filter_map(|modifier| node_text(modifier, content))
4020        .any(|modifier| {
4021            let modifier = modifier.trim();
4022            modifier.eq_ignore_ascii_case("private") || modifier.eq_ignore_ascii_case("protected")
4023        })
4024}
4025
4026/// Return whether a symbol name starts with an uppercase Unicode scalar.
4027fn starts_with_uppercase(value: &str) -> bool {
4028    value.chars().next().is_some_and(char::is_uppercase)
4029}
4030
4031/// Extract documentation attached to a declaration.
4032fn symbol_documentation(node: Node<'_>, content: &str) -> Option<String> {
4033    preceding_documentation(content, node.start_position().row + 1)
4034        .or_else(|| leading_docstring_literal(node, content))
4035}
4036
4037/// Extract contiguous doc-comment text immediately preceding a declaration.
4038fn preceding_documentation(content: &str, line_start: usize) -> Option<String> {
4039    let lines = content.lines().collect::<Vec<_>>();
4040    if line_start <= 1 || lines.is_empty() {
4041        return None;
4042    }
4043    let mut index = line_start.saturating_sub(2);
4044    let mut collected = Vec::new();
4045    let mut saw_doc = false;
4046    loop {
4047        let trimmed = lines[index].trim();
4048        if trimmed.is_empty() {
4049            break;
4050        }
4051        if !saw_doc && is_attribute_line(trimmed) {
4052            if index == 0 {
4053                break;
4054            }
4055            index -= 1;
4056            continue;
4057        }
4058        if let Some(line) = clean_doc_comment_line(trimmed) {
4059            collected.push(line);
4060            saw_doc = true;
4061            if index == 0 {
4062                break;
4063            }
4064            index -= 1;
4065            continue;
4066        }
4067        break;
4068    }
4069    collected.reverse();
4070    compact_documentation(&collected.join(" "))
4071}
4072
4073/// Return whether a line is a Rust or language attribute between docs and code.
4074fn is_attribute_line(trimmed: &str) -> bool {
4075    trimmed.starts_with("#[") || trimmed.starts_with('@')
4076}
4077
4078/// Strip common doc-comment markers from one line.
4079fn clean_doc_comment_line(trimmed: &str) -> Option<String> {
4080    let cleaned = if let Some(rest) = trimmed.strip_prefix("///") {
4081        rest
4082    } else if let Some(rest) = trimmed.strip_prefix("/**") {
4083        rest
4084    } else if let Some(rest) = trimmed.strip_prefix("*/") {
4085        rest
4086    } else if let Some(rest) = trimmed.strip_prefix('*') {
4087        rest
4088    } else if let Some(rest) = trimmed.strip_prefix("# ") {
4089        rest
4090    } else {
4091        trimmed.strip_prefix("## ")?
4092    }
4093    .trim()
4094    .trim_end_matches("*/")
4095    .trim()
4096    .to_string();
4097    Some(cleaned)
4098}
4099
4100/// Extract a Python-style leading string literal from a declaration body.
4101fn leading_docstring_literal(node: Node<'_>, content: &str) -> Option<String> {
4102    let mut cursor = node.walk();
4103    for child in node.named_children(&mut cursor) {
4104        if matches!(
4105            child.kind(),
4106            "block" | "statement_block" | "class_body" | "declaration_list"
4107        ) && let Some(docstring) = first_block_string_literal(child, content)
4108        {
4109            return Some(docstring);
4110        }
4111    }
4112    None
4113}
4114
4115/// Return the first string literal in a declaration body when it is the body lead.
4116fn first_block_string_literal(block: Node<'_>, content: &str) -> Option<String> {
4117    let mut cursor = block.walk();
4118    let first = block.named_children(&mut cursor).next()?;
4119    if first.kind() == "expression_statement" {
4120        let mut nested_cursor = first.walk();
4121        if let Some(string_node) = first
4122            .named_children(&mut nested_cursor)
4123            .find(|child| child.kind().contains("string"))
4124        {
4125            return clean_string_literal_doc(&node_text(string_node, content)?);
4126        }
4127    }
4128    if first.kind().contains("string") {
4129        return clean_string_literal_doc(&node_text(first, content)?);
4130    }
4131    None
4132}
4133
4134/// Clean a source string literal into documentation text.
4135fn clean_string_literal_doc(value: &str) -> Option<String> {
4136    let trimmed = value.trim();
4137    let unquoted = trimmed
4138        .strip_prefix("\"\"\"")
4139        .and_then(|text| text.strip_suffix("\"\"\""))
4140        .or_else(|| {
4141            trimmed
4142                .strip_prefix("'''")
4143                .and_then(|text| text.strip_suffix("'''"))
4144        })
4145        .or_else(|| {
4146            trimmed
4147                .strip_prefix('"')
4148                .and_then(|text| text.strip_suffix('"'))
4149        })
4150        .or_else(|| {
4151            trimmed
4152                .strip_prefix('\'')
4153                .and_then(|text| text.strip_suffix('\''))
4154        })
4155        .unwrap_or(trimmed);
4156    compact_documentation(unquoted)
4157}
4158
4159/// Normalize extracted documentation into one bounded line.
4160fn compact_documentation(value: &str) -> Option<String> {
4161    let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
4162    if compact.is_empty() {
4163        None
4164    } else {
4165        Some(truncate_chars(&compact, MAX_DOC_CHARS))
4166    }
4167}
4168
4169/// Find the nearest containing declaration symbol name.
4170fn enclosing_symbol_name(mut node: Option<Node<'_>>, content: &str) -> Option<String> {
4171    while let Some(current) = node {
4172        if current.kind() == "anonymous_class" {
4173            return None;
4174        }
4175        if declaration_kind(current.kind()).is_some()
4176            && let Some(name) = node_name(current, content)
4177        {
4178            return Some(name);
4179        }
4180        node = current.parent();
4181    }
4182    None
4183}
4184
4185/// Return UTF-8 text for a tree-sitter node.
4186fn node_text(node: Node<'_>, content: &str) -> Option<String> {
4187    node.utf8_text(content.as_bytes())
4188        .ok()
4189        .map(ToString::to_string)
4190}
4191
4192/// Extract symbols through conservative declaration regexes.
4193#[cfg(test)]
4194fn extract_fallback_graph(path: &str, language: Option<&str>, content: &str) -> SymbolGraph {
4195    match extract_fallback_graph_checked(path, language, content, &mut || Ok::<(), Infallible>(()))
4196    {
4197        Ok(graph) => graph,
4198        Err(unreachable) => match unreachable {},
4199    }
4200}
4201
4202/// Extract fallback symbols while observing cooperative parser control.
4203fn extract_fallback_graph_checked<E>(
4204    path: &str,
4205    language: Option<&str>,
4206    content: &str,
4207    check: &mut impl FnMut() -> Result<(), E>,
4208) -> Result<SymbolGraph, E> {
4209    check()?;
4210    let mut graph = empty_graph(path, language, ParserKind::Fallback);
4211    let patterns = fallback_patterns();
4212    check()?;
4213    for (line_index, line) in content.lines().enumerate() {
4214        check_parser_iteration(line_index, check)?;
4215        let trimmed = line.trim();
4216        for pattern in &patterns {
4217            if let Some(capture) = pattern.regex.captures(trimmed)
4218                && let Some(name) = capture.get(1)
4219            {
4220                push_symbol(
4221                    &mut graph,
4222                    name.as_str(),
4223                    pattern.kind,
4224                    line_index + 1,
4225                    line_index + 1,
4226                    None,
4227                    Some(pattern.detail),
4228                    trimmed,
4229                );
4230                break;
4231            }
4232        }
4233        if is_fallback_import(trimmed) {
4234            push_relation(
4235                &mut graph,
4236                MODULE_RELATION_SOURCE,
4237                trimmed,
4238                RelationKind::Imports,
4239                line_index + 1,
4240                trimmed,
4241            );
4242        }
4243    }
4244    check()?;
4245    languages::augment_fallback_language_graph(&mut graph, content, check)?;
4246    check()?;
4247    Ok(graph)
4248}
4249
4250/// Regex plus mapped symbol kind for fallback extraction.
4251struct FallbackPattern {
4252    /// Compiled fallback regex.
4253    regex: Regex,
4254    /// Symbol kind emitted when the regex matches.
4255    kind: SymbolKind,
4256    /// Stable detail string for the fallback source.
4257    detail: &'static str,
4258}
4259
4260/// Build fallback declaration regexes.
4261fn fallback_patterns() -> Vec<FallbackPattern> {
4262    let specs = [
4263        (
4264            r"^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)",
4265            SymbolKind::Function,
4266            "fallback-python-function",
4267        ),
4268        (
4269            r"^class\s+([A-Za-z_][A-Za-z0-9_]*)",
4270            SymbolKind::Class,
4271            "fallback-class",
4272        ),
4273        (
4274            r"^function\s+([A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z_][A-Za-z0-9_]*)+)\b",
4275            SymbolKind::Function,
4276            "fallback-powershell-function",
4277        ),
4278        (
4279            r"^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
4280            SymbolKind::Function,
4281            "fallback-js-function",
4282        ),
4283        (
4284            r"^(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:withDefaults\s*\(\s*)?(?:defineProps|defineEmits|defineModel|defineSlots|computed|ref|shallowRef|reactive|toRef|toRefs|watch)\b",
4285            SymbolKind::Value,
4286            "fallback-composition-binding",
4287        ),
4288        (
4289            r"^(?:pub\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)",
4290            SymbolKind::Function,
4291            "fallback-rust-function",
4292        ),
4293        (
4294            r"^(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)",
4295            SymbolKind::Type,
4296            "fallback-rust-type",
4297        ),
4298        (
4299            r"^(?:func|fun)\s+([A-Za-z_][A-Za-z0-9_]*)",
4300            SymbolKind::Function,
4301            "fallback-function",
4302        ),
4303        (
4304            r"^(?:public|private|protected|internal|static|\s)+\s*[A-Za-z0-9_<>,\[\]?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(",
4305            SymbolKind::Method,
4306            "fallback-c-family-method",
4307        ),
4308    ];
4309    let mut patterns = Vec::new();
4310    for (source, kind, detail) in specs {
4311        if let Ok(regex) = Regex::new(source) {
4312            patterns.push(FallbackPattern {
4313                regex,
4314                kind,
4315                detail,
4316            });
4317        }
4318    }
4319    patterns
4320}
4321
4322/// Return whether a line looks import-like in fallback mode.
4323fn is_fallback_import(line: &str) -> bool {
4324    matches!(
4325        line.split_whitespace().next(),
4326        Some("import" | "from" | "use" | "using" | "include" | "require")
4327    ) || line.starts_with("#include")
4328}
4329
4330/// Create an empty graph shell.
4331fn empty_graph(path: &str, language: Option<&str>, parser: ParserKind) -> SymbolGraph {
4332    SymbolGraph {
4333        path: path.to_string(),
4334        language: language.map(ToString::to_string),
4335        parser,
4336        symbols: Vec::new(),
4337        relations: Vec::new(),
4338    }
4339}
4340
4341/// Push a symbol while enforcing per-file graph bounds.
4342fn push_symbol(
4343    graph: &mut SymbolGraph,
4344    name: &str,
4345    kind: SymbolKind,
4346    line_start: usize,
4347    line_end: usize,
4348    parent: Option<String>,
4349    detail: Option<&str>,
4350    signature: &str,
4351) {
4352    push_symbol_with_metadata(
4353        graph, name, kind, line_start, line_end, parent, detail, signature, false, None,
4354    );
4355}
4356
4357/// Push a symbol with optional metadata while enforcing graph bounds.
4358fn push_symbol_with_metadata(
4359    graph: &mut SymbolGraph,
4360    name: &str,
4361    kind: SymbolKind,
4362    line_start: usize,
4363    line_end: usize,
4364    parent: Option<String>,
4365    detail: Option<&str>,
4366    signature: &str,
4367    exported: bool,
4368    documentation: Option<&str>,
4369) -> bool {
4370    if graph.symbols.len() >= MAX_SYMBOLS_PER_FILE {
4371        return false;
4372    }
4373    let Some(cleaned_name) = compact_symbol_identity(name) else {
4374        return false;
4375    };
4376    let parent = parent.and_then(|parent| compact_symbol_identity(&parent));
4377    graph.symbols.push(CodeSymbol {
4378        path: graph.path.clone(),
4379        language: graph.language.clone(),
4380        name: cleaned_name,
4381        kind,
4382        signature: truncate_chars_at_boundary(&compact_text(signature), MAX_SNIPPET_CHARS),
4383        exported,
4384        documentation: documentation.map(ToString::to_string),
4385        line_start,
4386        line_end: line_end.max(line_start),
4387        source_selector: None,
4388        parent,
4389        parser: graph.parser,
4390        detail: detail.map(ToString::to_string),
4391    });
4392    true
4393}
4394
4395/// Return one compact identity that can be represented by every graph consumer.
4396fn compact_symbol_identity(value: &str) -> Option<String> {
4397    let value = compact_text(value);
4398    (!value.is_empty()
4399        && value.chars().count() <= MAX_SNIPPET_CHARS
4400        && !value.starts_with(QUALIFIED_SYMBOL_SCOPE_PREFIX))
4401    .then_some(value)
4402}
4403
4404/// Push a relation while enforcing per-file graph bounds.
4405fn push_relation(
4406    graph: &mut SymbolGraph,
4407    source_name: &str,
4408    target_name: &str,
4409    kind: RelationKind,
4410    line: usize,
4411    context: &str,
4412) {
4413    if graph.relations.len() >= MAX_RELATIONS_PER_FILE {
4414        if is_php_language(graph.language.as_deref()) {
4415            graph.parser = ParserKind::Fallback;
4416        }
4417        return;
4418    }
4419    let target = compact_text(target_name);
4420    if target.is_empty() {
4421        return;
4422    }
4423    graph.relations.push(SymbolRelation {
4424        path: graph.path.clone(),
4425        source_name: truncate_chars_at_boundary(&compact_text(source_name), MAX_SNIPPET_CHARS),
4426        target_name: truncate_chars_at_boundary(&target, MAX_SNIPPET_CHARS),
4427        kind,
4428        line,
4429        context: truncate_chars_at_boundary(&compact_text(context), MAX_SNIPPET_CHARS),
4430        parser: graph.parser,
4431    });
4432}
4433
4434/// Push a bounded relation while preserving a target already decoded by a mapper.
4435fn push_relation_preserving_target(
4436    graph: &mut SymbolGraph,
4437    source_name: &str,
4438    target_name: &str,
4439    kind: RelationKind,
4440    line: usize,
4441    context: &str,
4442) {
4443    if graph.relations.len() >= MAX_RELATIONS_PER_FILE && is_php_language(graph.language.as_deref())
4444    {
4445        graph.parser = ParserKind::Fallback;
4446    }
4447    if graph.relations.len() >= MAX_RELATIONS_PER_FILE
4448        || target_name.is_empty()
4449        || target_name.chars().count() > MAX_SNIPPET_CHARS
4450        || context.chars().count() > MAX_SNIPPET_CHARS
4451    {
4452        return;
4453    }
4454    graph.relations.push(SymbolRelation {
4455        path: graph.path.clone(),
4456        source_name: truncate_chars_at_boundary(&compact_text(source_name), MAX_SNIPPET_CHARS),
4457        target_name: target_name.to_string(),
4458        kind,
4459        line,
4460        context: context.to_string(),
4461        parser: graph.parser,
4462    });
4463}
4464
4465/// Compact whitespace in a parser text fragment.
4466fn compact_text(value: &str) -> String {
4467    value.split_whitespace().collect::<Vec<_>>().join(" ")
4468}
4469
4470/// Truncate a string to a maximum number of Unicode scalar values.
4471fn truncate_chars(value: &str, max_chars: usize) -> String {
4472    if value.chars().count() <= max_chars {
4473        return value.to_string();
4474    }
4475    value.chars().take(max_chars).collect()
4476}
4477
4478/// Truncate a long snippet at a stable syntactic boundary and mark omission.
4479fn truncate_chars_at_boundary(value: &str, max_chars: usize) -> String {
4480    let value_chars = value.chars().count();
4481    if value_chars <= max_chars {
4482        return value.to_string();
4483    }
4484    let marker = "...";
4485    let marker_chars = marker.chars().count();
4486    if max_chars <= marker_chars {
4487        return value.chars().take(max_chars).collect();
4488    }
4489    let target_chars = max_chars - marker_chars;
4490    let mut fallback_end = 0_usize;
4491    let mut boundary_end = None;
4492    for (char_index, (index, character)) in value.char_indices().enumerate() {
4493        if char_index >= target_chars {
4494            break;
4495        }
4496        fallback_end = index + character.len_utf8();
4497        if is_snippet_boundary(character) {
4498            boundary_end = Some(fallback_end);
4499        }
4500    }
4501    let end = boundary_end.unwrap_or(fallback_end);
4502    let prefix = value[..end]
4503        .trim_end_matches(|character: char| {
4504            character.is_whitespace() || matches!(character, ',' | ';' | ':' | '{')
4505        })
4506        .to_string();
4507    if prefix.is_empty() {
4508        format!(
4509            "{}{marker}",
4510            value.chars().take(target_chars).collect::<String>()
4511        )
4512    } else {
4513        format!("{prefix}{marker}")
4514    }
4515}
4516
4517/// Return whether a character is a good truncation boundary for source snippets.
4518fn is_snippet_boundary(character: char) -> bool {
4519    character.is_whitespace()
4520        || matches!(
4521            character,
4522            ',' | ';' | ':' | '{' | '}' | '(' | ')' | '[' | ']' | '/' | '\\' | '.'
4523        )
4524}
4525
4526#[cfg(test)]
4527mod tests {
4528    use super::{
4529        MAX_RELATIONS_PER_FILE, MAX_SNIPPET_CHARS, MAX_SYMBOLS_PER_FILE, PhpNamespaceContext,
4530        QUALIFIED_SYMBOL_SCOPE_PREFIX, compact_symbol_identity,
4531        content_without_leading_purpose_header, empty_graph, extract_cargo_manifest_graph_checked,
4532        extract_fallback_graph, extract_fallback_graph_checked, extract_powershell_graph_checked,
4533        extract_symbol_graph, extract_symbol_graph_checked, extract_symbol_graph_controlled,
4534        extract_vue_sfc_graph_checked, languages, specialized_languages,
4535    };
4536    use projectatlas_core::symbols::{
4537        CodeSymbol, ParserKind, RelationKind, SymbolGraph, SymbolKind, SymbolSourceSelector,
4538    };
4539    use projectatlas_core::{
4540        IndexCancellation, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
4541    };
4542    use std::convert::Infallible;
4543    use std::fmt::Write as _;
4544
4545    fn tree_symbol<'a>(
4546        graph: &'a SymbolGraph,
4547        kind: SymbolKind,
4548        name: &str,
4549        parent: Option<&str>,
4550        signature_fragment: &str,
4551    ) -> Option<&'a CodeSymbol> {
4552        graph.symbols.iter().find(|symbol| {
4553            symbol.parser == ParserKind::TreeSitter
4554                && symbol.kind == kind
4555                && symbol.name == name
4556                && symbol.parent.as_deref() == parent
4557                && symbol.signature.contains(signature_fragment)
4558        })
4559    }
4560
4561    fn large_semicolon_namespace_source(declaration_count: usize) -> String {
4562        let mut source = String::from(
4563            "<?php\nnamespace Prefix;\nfunction prefix(): void {}\nnamespace Scale;\n",
4564        );
4565        for index in 0..(declaration_count - 1) {
4566            assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
4567        }
4568        source
4569    }
4570
4571    #[test]
4572    fn controlled_extraction_consumes_cancellation_and_preserves_compatibility() {
4573        let fixtures = [
4574            (
4575                "src/lib.rs",
4576                Some("rust"),
4577                "pub struct Atlas;\nimpl Atlas { pub fn scan(&self) {} }\n",
4578            ),
4579            (
4580                "Cargo.toml",
4581                Some("cargo-manifest"),
4582                "[package]\nname = \"atlas\"\n[dependencies]\nserde = \"1\"\n",
4583            ),
4584            (
4585                "Cargo.lock",
4586                Some("cargo-lock"),
4587                "[[package]]\nname = \"atlas\"\nversion = \"0.1.0\"\n",
4588            ),
4589            (
4590                "src/App.vue",
4591                Some("vue"),
4592                "const props = defineProps<{ id: string }>()\n",
4593            ),
4594            (
4595                "scripts/Invoke-Atlas.ps1",
4596                Some("powershell"),
4597                "function Invoke-Atlas { return 1 }\n",
4598            ),
4599            (
4600                "config/atlas.txt",
4601                Some("text"),
4602                "function fallbackOnly() {}\n",
4603            ),
4604            (
4605                "src/Service.php",
4606                Some("php"),
4607                "<?php class Service { public function run(): void {} }\n",
4608            ),
4609            (
4610                "src/Atlas.kt",
4611                Some("kotlin"),
4612                "package atlas\nclass Atlas {\nfun scan() {}\n}\n",
4613            ),
4614            ("build.gradle", Some("groovy"), "tasks.register('atlas')\n"),
4615        ];
4616        for &(path, language, source) in &fixtures {
4617            let expected = extract_symbol_graph(path, language, source);
4618            let active = IndexWorkControl::new(IndexCancellation::new(), None);
4619            assert_eq!(
4620                extract_symbol_graph_controlled(path, language, source, &active),
4621                Ok(expected),
4622                "controlled extraction changed compatibility output for {path}"
4623            );
4624        }
4625
4626        let cancellation = IndexCancellation::new();
4627        let control = IndexWorkControl::new(cancellation.clone(), None);
4628        let mut checkpoints = 0;
4629        let cancelled =
4630            extract_symbol_graph_checked("src/lib.rs", Some("rust"), fixtures[0].2, &mut || {
4631                checkpoints += 1;
4632                if checkpoints == 3 {
4633                    cancellation.cancel();
4634                }
4635                control.check(IndexWorkStage::SymbolParsing)
4636            });
4637        assert_eq!(
4638            cancelled,
4639            Err(IndexWorkFailure::Cancelled {
4640                stage: IndexWorkStage::SymbolParsing,
4641            })
4642        );
4643        assert_eq!(checkpoints, 3);
4644
4645        macro_rules! assert_parser_cancels_at {
4646            ($label:expr, $checkpoint:expr, $run:expr) => {{
4647                let cancellation = IndexCancellation::new();
4648                let control = IndexWorkControl::new(cancellation.clone(), None);
4649                let mut checkpoints = 0;
4650                let mut check = || {
4651                    checkpoints += 1;
4652                    if checkpoints == $checkpoint {
4653                        cancellation.cancel();
4654                    }
4655                    control.check(IndexWorkStage::SymbolParsing)
4656                };
4657                let cancelled = ($run)(&mut check);
4658                assert_eq!(
4659                    cancelled,
4660                    Err(IndexWorkFailure::Cancelled {
4661                        stage: IndexWorkStage::SymbolParsing,
4662                    }),
4663                    "{} did not observe cancellation inside its owned parser loop",
4664                    $label
4665                );
4666                assert_eq!(
4667                    checkpoints, $checkpoint,
4668                    "unexpected parser checkpoint path for {}",
4669                    $label
4670                );
4671            }};
4672        }
4673
4674        assert_parser_cancels_at!("Cargo manifest", 3, |check| {
4675            extract_cargo_manifest_graph_checked(
4676                fixtures[1].0,
4677                fixtures[1].1,
4678                fixtures[1].2,
4679                check,
4680                None,
4681            )
4682        });
4683        assert_parser_cancels_at!("fallback", 3, |check| {
4684            extract_fallback_graph_checked(fixtures[5].0, fixtures[5].1, fixtures[5].2, check)
4685        });
4686        assert_parser_cancels_at!("PHP tree-sitter", 3, |check| {
4687            extract_symbol_graph_checked(fixtures[6].0, fixtures[6].1, fixtures[6].2, check)
4688        });
4689        assert_parser_cancels_at!("Vue structural adapter", 8, |check| {
4690            extract_vue_sfc_graph_checked(fixtures[3].0, fixtures[3].1, fixtures[3].2, check)
4691        });
4692        assert_parser_cancels_at!("PowerShell structural adapter", 8, |check| {
4693            extract_powershell_graph_checked(fixtures[4].0, fixtures[4].1, fixtures[4].2, check)
4694        });
4695        assert_parser_cancels_at!("Markdown structural adapter", 2, |check| {
4696            super::markdown::extract_markdown_facts_checked(
4697                "# Guide\n\n[src](../src/lib.rs)\n",
4698                check,
4699            )
4700        });
4701
4702        let mut native_augmentation =
4703            empty_graph(fixtures[7].0, fixtures[7].1, ParserKind::TreeSitter);
4704        assert_parser_cancels_at!("native language augmentation", 3, |check| {
4705            languages::augment_language_graph(&mut native_augmentation, fixtures[7].2, check)
4706                .map(|()| native_augmentation.clone())
4707        });
4708
4709        let mut fallback_augmentation =
4710            empty_graph(fixtures[8].0, fixtures[8].1, ParserKind::Fallback);
4711        assert_parser_cancels_at!("fallback language augmentation", 4, |check| {
4712            languages::augment_fallback_language_graph(
4713                &mut fallback_augmentation,
4714                fixtures[8].2,
4715                check,
4716            )
4717            .map(|()| fallback_augmentation.clone())
4718        });
4719    }
4720
4721    #[test]
4722    fn supplied_language_selects_specialized_symbol_owner() {
4723        let cargo = extract_symbol_graph(
4724            "config/manifest.txt",
4725            Some("cargo-manifest"),
4726            "[package]\nname = \"atlas\"\n",
4727        );
4728        assert_eq!(cargo.parser, ParserKind::Manifest);
4729        assert!(
4730            cargo
4731                .symbols
4732                .iter()
4733                .any(|symbol| { symbol.kind == SymbolKind::Package && symbol.name == "atlas" })
4734        );
4735
4736        let vue = extract_symbol_graph(
4737            "config/component.txt",
4738            Some("vue"),
4739            "const count = ref(0);\n",
4740        );
4741        assert_eq!(vue.parser, ParserKind::Structural);
4742        assert!(vue.symbols.iter().any(|symbol| {
4743            symbol.name == "count" && symbol.detail.as_deref() == Some("vue-composition-binding")
4744        }));
4745
4746        let powershell = extract_symbol_graph(
4747            "config/script.txt",
4748            Some("powershell"),
4749            "function Get-Atlas { return 1 }\n",
4750        );
4751        assert_eq!(powershell.parser, ParserKind::Structural);
4752        assert!(powershell.symbols.iter().any(|symbol| {
4753            symbol.name == "Get-Atlas" && symbol.detail.as_deref() == Some("powershell-function")
4754        }));
4755
4756        for (path, source, forbidden_detail) in [
4757            (
4758                "Cargo.toml",
4759                "[package]\nname = \"atlas\"\n",
4760                "cargo-package",
4761            ),
4762            (
4763                "src/App.vue",
4764                "const count = ref(0);\n",
4765                "vue-composition-binding",
4766            ),
4767            (
4768                "scripts/Get-Atlas.ps1",
4769                "function Get-Atlas { return 1 }\n",
4770                "powershell-function",
4771            ),
4772        ] {
4773            let overridden = extract_symbol_graph(path, Some("text"), source);
4774            assert_eq!(overridden.parser, ParserKind::Structural, "{path}");
4775            assert!(overridden.symbols.is_empty(), "{path}");
4776            assert!(
4777                overridden
4778                    .symbols
4779                    .iter()
4780                    .all(|symbol| symbol.detail.as_deref() != Some(forbidden_detail)),
4781                "{path} ignored its supplied language: {:?}",
4782                overridden.symbols
4783            );
4784        }
4785
4786        let cargo_lock_override = extract_symbol_graph(
4787            "Cargo.toml",
4788            Some("cargo-lock"),
4789            "[[package]]\nname = \"atlas-lock\"\nversion = \"1.0.0\"\n",
4790        );
4791        assert!(cargo_lock_override.symbols.iter().any(|symbol| {
4792            symbol.kind == SymbolKind::Dependency && symbol.name == "atlas-lock"
4793        }));
4794    }
4795
4796    #[test]
4797    fn unavailable_symbol_owner_does_not_run_fallback_extraction() {
4798        let graph = extract_symbol_graph(
4799            "README.md",
4800            Some("markdown"),
4801            "pub fn forged_symbol() {}\nclass ForgedType {}\n",
4802        );
4803
4804        assert_eq!(graph.parser, ParserKind::Structural);
4805        assert!(graph.symbols.is_empty());
4806        assert!(graph.relations.is_empty());
4807    }
4808
4809    #[test]
4810    fn missing_language_preserves_specialized_symbol_path_inference() {
4811        let cargo = extract_symbol_graph("Cargo.toml", None, "[package]\nname = \"atlas\"\n");
4812        assert_eq!(cargo.parser, ParserKind::Manifest);
4813        assert!(
4814            cargo
4815                .symbols
4816                .iter()
4817                .any(|symbol| symbol.kind == SymbolKind::Package)
4818        );
4819
4820        let vue = extract_symbol_graph("src/App.vue", None, "const count = ref(0);\n");
4821        assert_eq!(vue.parser, ParserKind::Structural);
4822        assert!(
4823            vue.symbols
4824                .iter()
4825                .any(|symbol| { symbol.detail.as_deref() == Some("vue-composition-binding") })
4826        );
4827
4828        let powershell = extract_symbol_graph(
4829            "scripts/Get-Atlas.ps1",
4830            None,
4831            "function Get-Atlas { return 1 }\n",
4832        );
4833        assert_eq!(powershell.parser, ParserKind::Structural);
4834        assert!(
4835            powershell
4836                .symbols
4837                .iter()
4838                .any(|symbol| { symbol.detail.as_deref() == Some("powershell-function") })
4839        );
4840    }
4841
4842    #[test]
4843    fn extracts_rust_symbols_and_calls() {
4844        let source = r"
4845use std::fs;
4846
4847pub struct Atlas;
4848
4849impl Atlas {
4850    /// Run the atlas scan.
4851    pub fn scan(&self) {
4852        helper();
4853    }
4854}
4855
4856fn helper() {}
4857";
4858        let graph = extract_symbol_graph("src/lib.rs", Some("rust"), source);
4859        assert!(
4860            graph.symbols.iter().any(|symbol| {
4861                symbol.kind == SymbolKind::Struct && symbol.name.contains("Atlas")
4862            })
4863        );
4864        assert!(graph.symbols.iter().any(|symbol| {
4865            symbol.kind == SymbolKind::Function && symbol.name.contains("helper")
4866        }));
4867        assert!(graph.symbols.iter().any(|symbol| {
4868            symbol.kind == SymbolKind::Method
4869                && symbol.name.contains("scan")
4870                && symbol.parent.as_deref() == Some("Atlas")
4871                && symbol.exported
4872                && symbol.documentation.as_deref() == Some("Run the atlas scan.")
4873        }));
4874        assert!(graph.relations.iter().any(|relation| {
4875            relation.kind == RelationKind::Calls && relation.target_name.contains("helper")
4876        }));
4877    }
4878
4879    #[test]
4880    fn declaration_signatures_ignore_location_formatting_and_body_edits() {
4881        let before = extract_symbol_graph(
4882            "src/lib.rs",
4883            Some("rust"),
4884            "struct Atlas;\nimpl Atlas { pub fn run(&self, value: i32) -> i32 { value + 1 } }\n",
4885        );
4886        let after = extract_symbol_graph(
4887            "src/lib.rs",
4888            Some("rust"),
4889            "\n// moved\nstruct Atlas;\nimpl Atlas {\n pub fn run(\n  &self,\n  value: i32\n ) -> i32 {\n  value.saturating_mul(20)\n }\n}\n",
4890        );
4891        let before = tree_symbol(&before, SymbolKind::Method, "run", Some("Atlas"), "i32");
4892        let after = tree_symbol(&after, SymbolKind::Method, "run", Some("Atlas"), "i32");
4893        assert!(before.is_some() && after.is_some());
4894        let (Some(before), Some(after)) = (before, after) else {
4895            return;
4896        };
4897        assert_ne!(before.line_start, after.line_start);
4898        assert_eq!(before.signature, after.signature);
4899        assert!(!after.signature.contains("saturating_mul"));
4900
4901        let first = extract_symbol_graph(
4902            "src/run.ts",
4903            Some("typescript"),
4904            "export const run = (value: number): number => { return value + 1; };",
4905        );
4906        let changed = extract_symbol_graph(
4907            "src/run.ts",
4908            Some("typescript"),
4909            "export const run = (value: number): number => { return value * 20; };",
4910        );
4911        let first = tree_symbol(&first, SymbolKind::Function, "run", None, "number");
4912        let changed = tree_symbol(&changed, SymbolKind::Function, "run", None, "number");
4913        assert!(first.is_some() && changed.is_some());
4914        let (Some(first), Some(changed)) = (first, changed) else {
4915            return;
4916        };
4917        assert_eq!(first.signature, changed.signature);
4918        assert!(!first.signature.contains("return"));
4919    }
4920
4921    #[test]
4922    fn declaration_value_signatures_ignore_initializers_but_retain_declared_types() {
4923        for (path, language, before, initializer_changed, type_changed, name, declared_type) in [
4924            (
4925                "src/lib.rs",
4926                "rust",
4927                "pub const LIMIT: usize = 10;",
4928                "pub const LIMIT: usize = calculate_limit();",
4929                "pub const LIMIT: u64 = 10;",
4930                "LIMIT",
4931                "usize",
4932            ),
4933            (
4934                "src/config.ts",
4935                "typescript",
4936                "export const retryCount: number = 3;",
4937                "export const retryCount: number = calculateRetries();",
4938                "export const retryCount: string = '3';",
4939                "retryCount",
4940                "number",
4941            ),
4942        ] {
4943            let before = extract_symbol_graph(path, Some(language), before);
4944            let initializer_changed =
4945                extract_symbol_graph(path, Some(language), initializer_changed);
4946            let type_changed = extract_symbol_graph(path, Some(language), type_changed);
4947            let before = tree_symbol(&before, SymbolKind::Value, name, None, declared_type);
4948            let initializer_changed = tree_symbol(
4949                &initializer_changed,
4950                SymbolKind::Value,
4951                name,
4952                None,
4953                declared_type,
4954            );
4955            let type_changed = tree_symbol(&type_changed, SymbolKind::Value, name, None, "");
4956            assert!(before.is_some() && initializer_changed.is_some() && type_changed.is_some());
4957            let (Some(before), Some(initializer_changed), Some(type_changed)) =
4958                (before, initializer_changed, type_changed)
4959            else {
4960                return;
4961            };
4962            assert_eq!(before.signature, initializer_changed.signature);
4963            assert_ne!(before.signature, type_changed.signature);
4964            assert!(!initializer_changed.signature.contains("calculate"));
4965        }
4966    }
4967
4968    #[test]
4969    fn declaration_identity_material_disambiguates_overloads_and_parents() {
4970        let rust = extract_symbol_graph(
4971            "src/lib.rs",
4972            Some("rust"),
4973            "struct Left; impl Left { fn run(&self, value: i32) {} }\nstruct Right; impl Right { fn run(&self, value: i32) {} }\n",
4974        );
4975        let left = tree_symbol(&rust, SymbolKind::Method, "run", Some("Left"), "i32");
4976        let right = tree_symbol(&rust, SymbolKind::Method, "run", Some("Right"), "i32");
4977        assert!(left.is_some() && right.is_some());
4978        let (Some(left), Some(right)) = (left, right) else {
4979            return;
4980        };
4981        assert_eq!(left.signature, right.signature);
4982        assert_ne!(left.parent, right.parent);
4983
4984        let java = extract_symbol_graph(
4985            "src/Runner.java",
4986            Some("java"),
4987            "class Runner { int run(\n int value\n) { return value; } String run(\n String value\n) { return value; } }",
4988        );
4989        let numeric = tree_symbol(
4990            &java,
4991            SymbolKind::Method,
4992            "run",
4993            Some("Runner"),
4994            "int value",
4995        );
4996        let textual = tree_symbol(
4997            &java,
4998            SymbolKind::Method,
4999            "run",
5000            Some("Runner"),
5001            "String value",
5002        );
5003        assert!(numeric.is_some() && textual.is_some());
5004        let (Some(numeric), Some(textual)) = (numeric, textual) else {
5005            return;
5006        };
5007        assert_ne!(numeric.signature, textual.signature);
5008        assert_eq!(numeric.parent, textual.parent);
5009    }
5010
5011    #[test]
5012    fn native_parser_graph_survives_when_empty() {
5013        let graph = extract_symbol_graph("src/empty.rs", Some("rust"), "// comment only\n");
5014        assert_eq!(graph.parser, ParserKind::TreeSitter);
5015        assert!(graph.symbols.is_empty());
5016        assert!(graph.relations.is_empty());
5017    }
5018
5019    #[test]
5020    fn native_parser_ignores_fallback_patterns_inside_comments() {
5021        let graph = extract_symbol_graph(
5022            "src/commented.rs",
5023            Some("rust"),
5024            "/*\ndef leaked():\n    pass\nfunction leaked() {}\nimport x\n*/\n",
5025        );
5026        assert_eq!(graph.parser, ParserKind::TreeSitter);
5027        assert!(graph.symbols.is_empty());
5028        assert!(graph.relations.is_empty());
5029
5030        let graph = extract_symbol_graph(
5031            "src/commented.ts",
5032            Some("typescript"),
5033            "/*\ndef leaked():\n    pass\nfunction leaked() {}\nimport x\n*/\n",
5034        );
5035        assert_eq!(graph.parser, ParserKind::TreeSitter);
5036        assert!(graph.symbols.is_empty());
5037        assert!(graph.relations.is_empty());
5038    }
5039
5040    #[test]
5041    fn native_empty_graph_keeps_fallback_rescue() {
5042        let graph = extract_symbol_graph(
5043            "src/misdetected.rs",
5044            Some("rust"),
5045            "def rescued():\n    return 1\n",
5046        );
5047        assert_eq!(graph.parser, ParserKind::Fallback);
5048        assert!(graph.symbols.iter().any(|symbol| {
5049            symbol.name == "rescued"
5050                && symbol.kind == SymbolKind::Function
5051                && symbol.detail.as_deref() == Some("fallback-python-function")
5052        }));
5053    }
5054
5055    #[test]
5056    fn fallback_preserves_full_powershell_function_names() {
5057        let graph = extract_symbol_graph(
5058            "scripts/install-runtime.ps1",
5059            Some("powershell"),
5060            "class RuntimeConfig {\n}\nfunction Resolve-DefaultProjectRoot {\n}\nfunction Get-ReleaseRuntimeInstallPath {\n}\nfunction Install-ReleaseBinary {\n}\n",
5061        );
5062        assert_eq!(graph.parser, ParserKind::Structural);
5063        assert!(
5064            graph.symbols.iter().any(|symbol| {
5065                symbol.kind == SymbolKind::Class
5066                    && symbol.name == "RuntimeConfig"
5067                    && symbol.detail.as_deref() == Some("powershell-class")
5068            }),
5069            "missing PowerShell class symbol: {:?}",
5070            graph.symbols
5071        );
5072
5073        for name in [
5074            "Resolve-DefaultProjectRoot",
5075            "Get-ReleaseRuntimeInstallPath",
5076            "Install-ReleaseBinary",
5077        ] {
5078            assert!(
5079                graph.symbols.iter().any(|symbol| {
5080                    symbol.kind == SymbolKind::Function
5081                        && symbol.name == name
5082                        && symbol.detail.as_deref() == Some("powershell-function")
5083                }),
5084                "missing full PowerShell function name {name}: {:?}",
5085                graph.symbols
5086            );
5087        }
5088        assert!(
5089            !graph
5090                .symbols
5091                .iter()
5092                .any(|symbol| symbol.name == "Resolve" || symbol.name == "Install"),
5093            "PowerShell function names must not be truncated to verbs: {:?}",
5094            graph.symbols
5095        );
5096    }
5097
5098    #[test]
5099    fn extracts_typescript_symbols() {
5100        let source = r#"
5101import { readFile } from "fs";
5102export interface Reader { read(): string }
5103export class AtlasReader {
5104  read() { return readFile; }
5105}
5106export function createReader() { return new AtlasReader(); }
5107export const createWriter = () => createReader();
5108"#;
5109        let graph = extract_symbol_graph("src/index.ts", Some("typescript"), source);
5110        assert!(graph.symbols.iter().any(|symbol| {
5111            symbol.kind == SymbolKind::Interface
5112                && symbol.name.contains("Reader")
5113                && symbol.exported
5114        }));
5115        assert!(graph.symbols.iter().any(|symbol| {
5116            symbol.kind == SymbolKind::Class
5117                && symbol.name.contains("AtlasReader")
5118                && symbol.exported
5119        }));
5120        assert!(graph.symbols.iter().any(|symbol| {
5121            symbol.kind == SymbolKind::Function
5122                && symbol.name.contains("createReader")
5123                && symbol.exported
5124        }));
5125        assert!(graph.symbols.iter().any(|symbol| {
5126            symbol.kind == SymbolKind::Function && symbol.name == "createWriter" && symbol.exported
5127        }));
5128        assert!(graph.symbols.iter().any(|symbol| {
5129            symbol.kind == SymbolKind::Method
5130                && symbol.name == "read"
5131                && symbol.parent.as_deref() == Some("AtlasReader")
5132        }));
5133        assert!(graph.relations.iter().any(|relation| {
5134            relation.kind == RelationKind::Imports && relation.target_name.contains("readFile")
5135        }));
5136    }
5137
5138    #[test]
5139    fn host_import_reconciliation_owns_the_first_statement_not_the_whole_line() {
5140        for (path, language) in [
5141            ("view.vue", "vue"),
5142            ("view.svelte", "svelte"),
5143            ("view.html", "html"),
5144        ] {
5145            for first in [
5146                "import {run as first} from './a.js';".to_string(),
5147                format!(
5148                    "import /*{}*/ {{run as first}} from './a.js';",
5149                    "comment ".repeat(80)
5150                ),
5151                "import {\nrun as first\n} from './a.js';".to_string(),
5152            ] {
5153                let role = match language {
5154                    "html" => "type=module",
5155                    "svelte" => "module",
5156                    _ => "",
5157                };
5158                let content = format!(
5159                    "<script {role}>\n{first} export {{first as again}}; import {{run as second}} from './b.js';\nfunction caller() {{first(); second();}}\n</script>\n<script lang=\"coffee\">\nimport missing from './unprojected.js';\n</script>"
5160                );
5161                let result = super::extract_symbol_graph_with_semantics_controlled(
5162                    path,
5163                    Some(language),
5164                    &content,
5165                    &projectatlas_core::IndexWorkControl::new(
5166                        projectatlas_core::IndexCancellation::new(),
5167                        None,
5168                    ),
5169                );
5170                assert!(result.is_ok(), "{result:?}");
5171                let Ok(extracted) = result else {
5172                    return;
5173                };
5174                let graph = extracted.graph();
5175                let imports = graph
5176                    .relations
5177                    .iter()
5178                    .filter(|relation| relation.kind == RelationKind::Imports)
5179                    .collect::<Vec<_>>();
5180                let expected_unsupported = usize::from(language != "html");
5181                assert_eq!(
5182                    imports.len(),
5183                    2 + expected_unsupported,
5184                    "{language}: {first}: {imports:?}"
5185                );
5186                assert_eq!(
5187                    extracted.semantics().imports().len(),
5188                    2,
5189                    "unsupported region gained native proof"
5190                );
5191                for import in extracted.semantics().imports() {
5192                    assert_eq!(
5193                        graph.relations[import.relation].parser,
5194                        ParserKind::TreeSitter,
5195                        "AST-owned import retained rescue trust"
5196                    );
5197                }
5198                assert_eq!(extracted.semantics().calls().len(), 2);
5199                for call in extracted.semantics().calls() {
5200                    assert_eq!(
5201                        graph.relations[call.relation].parser,
5202                        ParserKind::TreeSitter,
5203                        "AST-owned call retained rescue trust"
5204                    );
5205                }
5206                if expected_unsupported != 0 {
5207                    assert!(imports.iter().any(|relation| {
5208                        relation.target_name.contains("unprojected.js")
5209                            && relation.parser != ParserKind::TreeSitter
5210                    }));
5211                }
5212                assert!(
5213                    imports
5214                        .iter()
5215                        .all(|relation| !relation.target_name.contains("export {first")),
5216                    "whole-line rescue retained sibling statements"
5217                );
5218                let graph_only = extract_symbol_graph(path, Some(language), &content);
5219                assert_eq!(
5220                    graph_only.relations, graph.relations,
5221                    "graph-only and typed host paths diverged"
5222                );
5223            }
5224        }
5225    }
5226
5227    #[test]
5228    fn typescript_nested_locals_do_not_inherit_exported_parent() {
5229        let source = r#"
5230export function useAtlas() {
5231  type LocalMode = "fast" | "safe";
5232  const localCache = new Map<string, string>();
5233  const computeLocal = () => localCache.size;
5234  return computeLocal();
5235}
5236"#;
5237        let graph = extract_symbol_graph("src/use-atlas.ts", Some("typescript"), source);
5238        assert!(graph.symbols.iter().any(|symbol| {
5239            symbol.kind == SymbolKind::Function && symbol.name == "useAtlas" && symbol.exported
5240        }));
5241        assert!(graph.symbols.iter().any(|symbol| {
5242            symbol.name == "LocalMode"
5243                && symbol.parent.as_deref() == Some("useAtlas")
5244                && !symbol.exported
5245        }));
5246        for nested_value in ["localCache", "computeLocal"] {
5247            assert!(
5248                graph.symbols.iter().any(|symbol| {
5249                    symbol.name == nested_value
5250                        && symbol.kind == SymbolKind::Value
5251                        && symbol.parent.as_deref() == Some("useAtlas")
5252                        && !symbol.exported
5253                }),
5254                "nested value {nested_value} should remain indexed with parent and no export"
5255            );
5256        }
5257    }
5258
5259    #[test]
5260    fn javascript_summary_symbols_ignore_locals_and_iife_constants() {
5261        let source = r#"
5262import path from "node:path";
5263import { createHash } from "node:crypto";
5264
5265const DATA_DIRECTORY = path.resolve("app/public/data");
5266const OUTPUT_FILE = path.join(DATA_DIRECTORY, "datasets.manifest.json");
5267const CACHE_NAME = (() => `sw-${Date.now()}`)();
5268
5269function sha256(value) {
5270  return createHash("sha256").update(value).digest("hex");
5271}
5272
5273async function readDatasetEntry(filePath) {
5274  return sha256(filePath);
5275}
5276
5277async function main() {
5278  const datasetEntries = await Promise.all(["a"].map((file) => readDatasetEntry(file)));
5279  const versionSeed = datasetEntries.map((entry) => entry.id).join("\n");
5280  return versionSeed;
5281}
5282"#;
5283        let graph = extract_symbol_graph("scripts/generate.mjs", Some("javascript"), source);
5284        for name in ["sha256", "readDatasetEntry", "main"] {
5285            assert!(
5286                graph
5287                    .symbols
5288                    .iter()
5289                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == name),
5290                "missing top-level function {name}"
5291            );
5292        }
5293        for name in ["DATA_DIRECTORY", "OUTPUT_FILE", "CACHE_NAME"] {
5294            assert!(
5295                graph
5296                    .symbols
5297                    .iter()
5298                    .any(|symbol| symbol.kind == SymbolKind::Value && symbol.name == name),
5299                "missing top-level constant {name}"
5300            );
5301            assert!(
5302                !graph
5303                    .symbols
5304                    .iter()
5305                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == name),
5306                "constant {name} must not be promoted to a function"
5307            );
5308        }
5309        for local in ["datasetEntries", "versionSeed"] {
5310            assert!(
5311                graph
5312                    .symbols
5313                    .iter()
5314                    .any(|symbol| symbol.kind == SymbolKind::Value && symbol.name == local),
5315                "local binding {local} should remain indexed as a nested value"
5316            );
5317            assert!(
5318                !graph
5319                    .symbols
5320                    .iter()
5321                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == local),
5322                "local binding {local} must not become a function"
5323            );
5324        }
5325    }
5326
5327    #[test]
5328    fn javascript_object_literal_methods_are_not_file_level_methods() {
5329        let source = r"
5330const stub = {
5331  addListener() {},
5332  removeListener() {},
5333  nested: {
5334    addEventListener() {},
5335    removeEventListener() {}
5336  }
5337};
5338
5339class Harness {
5340  run() {}
5341}
5342";
5343        let graph = extract_symbol_graph("tests/browser.spec.js", Some("javascript"), source);
5344        for object_method in [
5345            "addListener",
5346            "removeListener",
5347            "addEventListener",
5348            "removeEventListener",
5349        ] {
5350            assert!(
5351                !graph
5352                    .symbols
5353                    .iter()
5354                    .any(|symbol| symbol.name == object_method),
5355                "object literal method {object_method} must not become a file-level method"
5356            );
5357        }
5358        assert!(
5359            graph
5360                .symbols
5361                .iter()
5362                .any(|symbol| { symbol.kind == SymbolKind::Class && symbol.name == "Harness" })
5363        );
5364        assert!(graph.symbols.iter().any(|symbol| {
5365            symbol.kind == SymbolKind::Method
5366                && symbol.name == "run"
5367                && symbol.parent.as_deref() == Some("Harness")
5368        }));
5369    }
5370
5371    #[test]
5372    fn javascript_exported_object_literal_methods_remain_indexed() {
5373        let source = r"
5374export const api = {
5375  list() {},
5376  nested: {
5377    refresh() {}
5378  }
5379};
5380
5381module.exports = {
5382  boot() {}
5383};
5384";
5385        let graph = extract_symbol_graph("src/api.js", Some("javascript"), source);
5386        assert!(graph.symbols.iter().any(|symbol| {
5387            symbol.kind == SymbolKind::Method
5388                && symbol.name == "list"
5389                && symbol.parent.as_deref() == Some("api")
5390                && symbol.exported
5391        }));
5392        assert!(graph.symbols.iter().any(|symbol| {
5393            symbol.kind == SymbolKind::Method
5394                && symbol.name == "refresh"
5395                && symbol.parent.as_deref() == Some("api.nested")
5396                && symbol.exported
5397        }));
5398        assert!(graph.symbols.iter().any(|symbol| {
5399            symbol.kind == SymbolKind::Method
5400                && symbol.name == "boot"
5401                && symbol.parent.as_deref() == Some("module.exports")
5402                && symbol.exported
5403        }));
5404    }
5405
5406    #[test]
5407    fn javascript_direct_callable_constants_remain_functions() {
5408        let source = r#"
5409export const createThing = () => ({ kind: "thing" });
5410const helper = function helperFactory() { return createThing(); };
5411"#;
5412        let graph = extract_symbol_graph("src/factory.js", Some("javascript"), source);
5413        for name in ["createThing", "helper"] {
5414            assert!(
5415                graph
5416                    .symbols
5417                    .iter()
5418                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == name),
5419                "callable constant {name} should remain function-like"
5420            );
5421        }
5422    }
5423
5424    #[test]
5425    fn file_purpose_docblock_is_not_symbol_documentation() {
5426        let source = r#"/**
5427 * Purpose: Choose a fresher catalog start so repeated app opens avoid the same opening items.
5428 */
5429import type { CatalogItem } from "@/types/catalog";
5430export function applyLaunchFreshness() {}
5431"#;
5432        let graph = extract_symbol_graph("src/launch-freshness.ts", Some("typescript"), source);
5433        assert!(
5434            graph
5435                .symbols
5436                .iter()
5437                .any(|symbol| symbol.kind == SymbolKind::Import && symbol.documentation.is_none())
5438        );
5439        assert!(
5440            graph
5441                .symbols
5442                .iter()
5443                .any(|symbol| symbol.name == "applyLaunchFreshness"
5444                    && symbol.documentation.is_none())
5445        );
5446    }
5447
5448    #[test]
5449    fn boundary_truncates_long_import_snippet() {
5450        let truncated = super::truncate_chars_at_boundary(
5451            "import type { DigestDraft, DeliveryChannel, CatalogDatasetItem } from \"@/catalog\";",
5452            56,
5453        );
5454
5455        assert_eq!(truncated, "import type { DigestDraft, DeliveryChannel...");
5456    }
5457
5458    #[test]
5459    fn import_specific_comment_remains_import_documentation() {
5460        let source = r#"/** Loads a required browser polyfill. */
5461import "./polyfill";
5462"#;
5463        let graph = extract_symbol_graph("src/polyfills.ts", Some("typescript"), source);
5464        assert!(
5465            graph.symbols.iter().any(|symbol| {
5466                symbol.kind == SymbolKind::Import
5467                    && symbol.documentation.as_deref() == Some("Loads a required browser polyfill.")
5468            }),
5469            "import-specific documentation should remain attached to the import symbol"
5470        );
5471    }
5472
5473    #[test]
5474    fn extracts_vue_composition_bindings_from_script_setup() {
5475        let source = r#"
5476<template><article>{{ currentPriceLabel }}</article></template>
5477<script setup lang="ts">
5478import { computed, ref } from "vue";
5479
5480const props = withDefaults(defineProps<{
5481  title: string;
5482}>(), { title: "Product" });
5483const emit = defineEmits<{
5484  select: [id: string];
5485}>();
5486const productTitleId = computed(() => props.title.toLowerCase());
5487const currentPriceLabel = computed(() => `$${props.title}`);
5488const retryCount = ref(0);
5489</script>
5490"#;
5491        let graph = extract_symbol_graph("src/ProductPanel.vue", Some("vue"), source);
5492        for expected in [
5493            "props",
5494            "emit",
5495            "productTitleId",
5496            "currentPriceLabel",
5497            "retryCount",
5498        ] {
5499            assert!(
5500                graph.symbols.iter().any(|symbol| {
5501                    symbol.kind == SymbolKind::Value
5502                        && symbol.name == expected
5503                        && symbol.detail.as_deref() == Some("vue-composition-binding")
5504                        && symbol.parser == ParserKind::Structural
5505                }),
5506                "missing Vue Composition API binding {expected}"
5507            );
5508        }
5509        assert_eq!(graph.parser, ParserKind::Structural);
5510        assert!(graph.relations.iter().any(|relation| {
5511            relation.kind == RelationKind::Imports
5512                && relation.target_name.contains("computed")
5513                && relation.parser == ParserKind::TreeSitter
5514        }));
5515        assert!(
5516            graph
5517                .symbols
5518                .iter()
5519                .all(|symbol| symbol.parser == ParserKind::Structural)
5520        );
5521        assert!(graph.relations.iter().any(|relation| {
5522            relation.kind == RelationKind::Calls
5523                && relation.target_name == "computed"
5524                && relation.parser == ParserKind::TreeSitter
5525        }));
5526    }
5527
5528    #[test]
5529    fn extracts_embedded_html_and_svelte_facts_at_host_lines() {
5530        let html = r#"<main>not source</main>
5531<script lang="ts">
5532export interface ProductRecord { id: string }
5533export function loadProduct() { return ProductRecord; }
5534</script>
5535"#;
5536        let graph = extract_symbol_graph("public/index.html", Some("html"), html);
5537        assert_eq!(graph.parser, ParserKind::Structural);
5538        assert!(graph.symbols.iter().any(|symbol| {
5539            symbol.name == "ProductRecord"
5540                && symbol.kind == SymbolKind::Interface
5541                && symbol.line_start == 3
5542                && symbol.parser == ParserKind::TreeSitter
5543                && symbol.language.as_deref() == Some("typescript")
5544        }));
5545        assert!(graph.symbols.iter().any(|symbol| {
5546            symbol.name == "loadProduct"
5547                && symbol.line_start == 4
5548                && symbol.parser == ParserKind::TreeSitter
5549        }));
5550
5551        let svelte = r#"<h1>{title}</h1>
5552<script lang="ts">
5553export interface PageData { title: string }
5554</script>
5555"#;
5556        let graph = extract_symbol_graph("src/Page.svelte", Some("svelte"), svelte);
5557        assert_eq!(graph.parser, ParserKind::Fallback);
5558        assert!(graph.symbols.iter().any(|symbol| {
5559            symbol.name == "PageData"
5560                && symbol.kind == SymbolKind::Interface
5561                && symbol.line_start == 3
5562                && symbol.parser == ParserKind::TreeSitter
5563        }));
5564    }
5565
5566    #[test]
5567    fn embedded_hosts_ignore_external_and_retain_only_safe_facts_on_incomplete_input() {
5568        for source in [
5569            "<script src=\"external.js\">export function forged() {}</script>",
5570            "<script lang=\"ts\">export function incomplete() {}",
5571        ] {
5572            let graph = extract_symbol_graph("public/index.html", Some("html"), source);
5573            assert!(
5574                graph.symbols.is_empty(),
5575                "unexpected symbols: {:?}",
5576                graph.symbols
5577            );
5578            assert!(
5579                graph.relations.is_empty(),
5580                "unexpected relations: {:?}",
5581                graph.relations
5582            );
5583        }
5584
5585        let source = "<script>export function admitted() {}</script>"
5586            .repeat(super::semantic::embedded_source::MAX_EMBEDDED_SCRIPT_REGIONS + 1);
5587        let graph = extract_symbol_graph("public/index.html", Some("html"), &source);
5588        assert!(graph.symbols.iter().any(|symbol| symbol.name == "admitted"));
5589        assert_eq!(graph.parser, ParserKind::Structural);
5590    }
5591
5592    #[test]
5593    fn purpose_header_mask_preserves_utf8_byte_offsets_for_embedded_hosts() {
5594        let source = concat!(
5595            "<!-- Purpose: Grüße and routing -->\n",
5596            "<script lang=\"ts\">export const admitted = 1;</script>\n"
5597        );
5598        let masked = content_without_leading_purpose_header(source);
5599        assert_eq!(masked.len(), source.len());
5600        assert_eq!(masked.find("<script"), source.find("<script"));
5601
5602        let graph = extract_symbol_graph("public/index.html", Some("html"), source);
5603        assert!(graph.symbols.iter().any(|symbol| {
5604            symbol.name == "admitted"
5605                && symbol.line_start == 2
5606                && symbol.parser == ParserKind::TreeSitter
5607        }));
5608    }
5609
5610    #[test]
5611    fn vue_sfc_preserves_fallback_declarations() {
5612        let source = r#"
5613<script lang="ts">
5614export function submitOrder() {
5615  return true;
5616}
5617
5618class Store {
5619}
5620</script>
5621<script setup lang="ts">
5622import { ref } from "vue";
5623const selected = ref(false);
5624</script>
5625"#;
5626        let graph = extract_symbol_graph("src/CheckoutPanel.vue", Some("vue"), source);
5627
5628        assert!(graph.symbols.iter().any(|symbol| {
5629            symbol.kind == SymbolKind::Value
5630                && symbol.name == "selected"
5631                && symbol.detail.as_deref() == Some("vue-composition-binding")
5632                && symbol.parser == ParserKind::Structural
5633        }));
5634        assert!(graph.symbols.iter().any(|symbol| {
5635            symbol.kind == SymbolKind::Function
5636                && symbol.name == "submitOrder"
5637                && symbol.detail.as_deref() == Some("fallback-js-function")
5638                && symbol.parser == ParserKind::Fallback
5639        }));
5640        assert!(graph.symbols.iter().any(|symbol| {
5641            symbol.kind == SymbolKind::Class
5642                && symbol.name == "Store"
5643                && symbol.detail.as_deref() == Some("fallback-class")
5644                && symbol.parser == ParserKind::Fallback
5645        }));
5646    }
5647
5648    #[test]
5649    fn vue_sfc_preserves_fallback_declarations_when_bindings_exceed_cap() {
5650        let mut source = String::from(
5651            r#"
5652<script setup lang="ts">
5653export function submitOrder() {
5654  return true;
5655}
5656
5657class Store {
5658}
5659"#,
5660        );
5661        for index in 0..(MAX_SYMBOLS_PER_FILE + 50) {
5662            source.push_str("const value");
5663            source.push_str(&index.to_string());
5664            source.push_str(" = ref(false);\n");
5665        }
5666        source.push_str("</script>\n");
5667
5668        let graph = extract_symbol_graph("src/LargePanel.vue", Some("vue"), &source);
5669
5670        assert!(graph.symbols.iter().any(|symbol| {
5671            symbol.kind == SymbolKind::Function
5672                && symbol.name == "submitOrder"
5673                && symbol.detail.as_deref() == Some("fallback-js-function")
5674        }));
5675        assert!(graph.symbols.iter().any(|symbol| {
5676            symbol.kind == SymbolKind::Class
5677                && symbol.name == "Store"
5678                && symbol.detail.as_deref() == Some("fallback-class")
5679        }));
5680        assert!(graph.symbols.iter().any(|symbol| {
5681            symbol.kind == SymbolKind::Value
5682                && symbol.name == "value0"
5683                && symbol.detail.as_deref() == Some("vue-composition-binding")
5684                && symbol.parser == ParserKind::Structural
5685        }));
5686    }
5687
5688    #[test]
5689    fn vue_composition_binding_detection_requires_macro_call_boundary() {
5690        let source = r#"
5691<script setup lang="ts">
5692const data = refreshData();
5693const value = computedValue();
5694const typed = ref<string>("ok");
5695const delayed = computed (() => typed.value);
5696const props = withDefaults(defineProps<{ title: string }>(), { title: "Product" });
5697</script>
5698"#;
5699        let graph = extract_symbol_graph("src/Widget.vue", Some("vue"), source);
5700
5701        for absent in ["data", "value"] {
5702            assert!(
5703                graph.symbols.iter().all(|symbol| symbol.name != absent),
5704                "ordinary function call {absent} was incorrectly treated as a Vue binding"
5705            );
5706        }
5707        for expected in ["typed", "delayed", "props"] {
5708            assert!(
5709                graph.symbols.iter().any(|symbol| {
5710                    symbol.kind == SymbolKind::Value
5711                        && symbol.name == expected
5712                        && symbol.detail.as_deref() == Some("vue-composition-binding")
5713                }),
5714                "missing Vue macro binding {expected}"
5715            );
5716        }
5717    }
5718
5719    #[test]
5720    fn extracts_python_docstrings() {
5721        let source = r#"
5722class Builder:
5723    """Builds atlas state."""
5724
5725    def build(self):
5726        """Build the atlas."""
5727        return "atlas"
5728"#;
5729        let graph = extract_symbol_graph("src/builder.py", Some("python"), source);
5730        assert!(graph.symbols.iter().any(|symbol| {
5731            symbol.kind == SymbolKind::Class
5732                && symbol.name == "Builder"
5733                && symbol.documentation.as_deref() == Some("Builds atlas state.")
5734        }));
5735        assert!(graph.symbols.iter().any(|symbol| {
5736            symbol.kind == SymbolKind::Method
5737                && symbol.name == "build"
5738                && symbol.documentation.as_deref() == Some("Build the atlas.")
5739                && symbol.parent.as_deref() == Some("Builder")
5740        }));
5741    }
5742
5743    #[test]
5744    fn extracts_java_package_classes_methods_and_calls() {
5745        let source = r"
5746package com.example.atlas;
5747
5748public class AtlasService {
5749    public void run() {
5750        helper();
5751    }
5752
5753    private void helper() {}
5754}
5755";
5756        let graph = extract_symbol_graph("src/AtlasService.java", Some("java"), source);
5757        assert!(graph.symbols.iter().any(|symbol| {
5758            symbol.kind == SymbolKind::Module && symbol.name == "com.example.atlas"
5759        }));
5760        assert!(graph.symbols.iter().any(|symbol| {
5761            symbol.kind == SymbolKind::Class && symbol.name == "AtlasService" && symbol.exported
5762        }));
5763        assert!(graph.symbols.iter().any(|symbol| {
5764            symbol.kind == SymbolKind::Method
5765                && symbol.name == "run"
5766                && symbol.parent.as_deref() == Some("AtlasService")
5767                && symbol.exported
5768        }));
5769        assert!(graph.relations.iter().any(|relation| {
5770            relation.kind == RelationKind::Calls && relation.target_name == "helper"
5771        }));
5772    }
5773
5774    #[test]
5775    fn extracts_go_package_functions_methods_and_imports() {
5776        let source = r#"
5777package atlas
5778
5779import "fmt"
5780
5781type Runner struct {}
5782
5783func (r Runner) Run() {
5784    helper()
5785}
5786
5787func helper() {
5788    fmt.Println("ok")
5789}
5790"#;
5791        let graph = extract_symbol_graph("service.go", Some("go"), source);
5792        assert!(
5793            graph
5794                .symbols
5795                .iter()
5796                .any(|symbol| { symbol.kind == SymbolKind::Module && symbol.name == "atlas" })
5797        );
5798        assert!(
5799            graph
5800                .symbols
5801                .iter()
5802                .any(|symbol| { symbol.kind == SymbolKind::Struct && symbol.name == "Runner" })
5803        );
5804        assert!(graph.symbols.iter().any(|symbol| {
5805            symbol.kind == SymbolKind::Method && symbol.name == "Run" && symbol.exported
5806        }));
5807        assert!(
5808            graph
5809                .symbols
5810                .iter()
5811                .any(|symbol| { symbol.kind == SymbolKind::Function && symbol.name == "helper" })
5812        );
5813        assert!(
5814            graph
5815                .symbols
5816                .iter()
5817                .any(|symbol| { symbol.kind == SymbolKind::Import && symbol.name == "\"fmt\"" })
5818        );
5819        assert!(graph.relations.iter().any(|relation| {
5820            relation.kind == RelationKind::Imports && relation.target_name.contains("\"fmt\"")
5821        }));
5822    }
5823
5824    #[test]
5825    fn braced_container_names_exclude_bodies_and_retain_declaration_parents() {
5826        for (language, source, namespace, child) in [
5827            (
5828                "csharp",
5829                "namespace N { class C { static void Run() {} } }",
5830                "N",
5831                Some("C"),
5832            ),
5833            (
5834                "csharp",
5835                "namespace A.B {\n class C {}\n}",
5836                "A.B",
5837                Some("C"),
5838            ),
5839            ("csharp", "namespace @N { class C {} }", "@N", Some("C")),
5840            ("csharp", "namespace N;\nclass C {}", "N", None),
5841            (
5842                "java",
5843                "module atlas.core { exports atlas.api; }",
5844                "atlas.core",
5845                None,
5846            ),
5847            ("java", "package atlas.core; class C {}", "atlas.core", None),
5848            ("go", "package atlas\nfunc run() {}", "atlas", None),
5849            (
5850                "kotlin",
5851                "package atlas.core\nclass C {}",
5852                "atlas.core",
5853                None,
5854            ),
5855            ("cpp", "namespace N { class C {}; }", "N", Some("C")),
5856        ] {
5857            let graph = extract_symbol_graph("containers.source", Some(language), source);
5858            assert!(
5859                graph
5860                    .symbols
5861                    .iter()
5862                    .any(|symbol| symbol.kind == SymbolKind::Module && symbol.name == namespace),
5863                "{language}: {graph:?}"
5864            );
5865            if let Some(child) = child {
5866                assert!(
5867                    graph.symbols.iter().any(|symbol| symbol.name == child
5868                        && symbol.parent.as_deref() == Some(namespace)),
5869                    "{language}: {graph:?}"
5870                );
5871            }
5872        }
5873        for (language, source) in [
5874            ("csharp", "namespace { class C {} }"),
5875            ("java", "module { exports atlas.api; }"),
5876        ] {
5877            let graph = extract_symbol_graph("malformed.source", Some(language), source);
5878            assert!(
5879                !graph
5880                    .symbols
5881                    .iter()
5882                    .any(|symbol| symbol.kind == SymbolKind::Module),
5883                "{language}: {graph:?}"
5884            );
5885        }
5886    }
5887
5888    #[test]
5889    fn extracts_csharp_namespace_classes_and_methods() {
5890        let source = r"
5891namespace Atlas.Core;
5892
5893public class Runner
5894{
5895    public void Run()
5896    {
5897        Helper();
5898    }
5899
5900    private void Helper() {}
5901}
5902";
5903        let graph = extract_symbol_graph("Runner.cs", Some("csharp"), source);
5904        assert!(
5905            graph
5906                .symbols
5907                .iter()
5908                .any(|symbol| { symbol.kind == SymbolKind::Module && symbol.name == "Atlas.Core" })
5909        );
5910        assert!(graph.symbols.iter().any(|symbol| {
5911            symbol.kind == SymbolKind::Class && symbol.name == "Runner" && symbol.exported
5912        }));
5913        assert!(graph.symbols.iter().any(|symbol| {
5914            symbol.kind == SymbolKind::Method
5915                && symbol.name == "Run"
5916                && symbol.parent.as_deref() == Some("Runner")
5917                && symbol.exported
5918        }));
5919    }
5920
5921    #[test]
5922    fn csharp_field_identity_is_stable_across_large_initializer_boundary() {
5923        for entry_count in [224, 225] {
5924            let mut entries = String::new();
5925            for index in 0..entry_count {
5926                let result = write!(entries, "[\"key{index}\"] = \"value{index}\",");
5927                assert!(result.is_ok(), "writing to a String must succeed");
5928            }
5929            let source = format!(
5930                r"
5931using System.Collections.Generic;
5932
5933public class Registry
5934{{
5935    public static readonly Dictionary<string, string> D = new()
5936    {{
5937        {entries}
5938    }};
5939}}
5940"
5941            );
5942
5943            let graph = extract_symbol_graph("Registry.cs", Some("csharp"), &source);
5944
5945            assert!(
5946                graph
5947                    .symbols
5948                    .iter()
5949                    .any(|symbol| symbol.kind == SymbolKind::Value && symbol.name == "D"),
5950                "missing exact D identity with {entry_count} initializer entries"
5951            );
5952            assert!(
5953                graph
5954                    .symbols
5955                    .iter()
5956                    .all(|symbol| !symbol.name.contains("Dictionary") && !symbol.name.contains('=')),
5957                "complete declaration became an identity with {entry_count} initializer entries"
5958            );
5959        }
5960    }
5961
5962    #[test]
5963    fn invalid_csharp_field_identities_do_not_hide_valid_siblings() {
5964        let admitted_unicode_name = "名".repeat(MAX_SNIPPET_CHARS);
5965        let overbound_unicode_name = "名".repeat(MAX_SNIPPET_CHARS + 1);
5966        let source = format!(
5967            r"
5968using System.Collections.Generic;
5969
5970public class Registry
5971{{
5972    public int Before = 1;
5973    public int {admitted_unicode_name} = 2;
5974    public int {overbound_unicode_name} = 3;
5975    public static readonly Dictionary<string, string> = new();
5976    public int After = 4;
5977}}
5978"
5979        );
5980
5981        let graph = extract_symbol_graph("Registry.cs", Some("csharp"), &source);
5982
5983        for expected in ["Before", admitted_unicode_name.as_str(), "After"] {
5984            assert!(
5985                graph.symbols.iter().any(|symbol| symbol.name == expected),
5986                "missing valid sibling {expected}"
5987            );
5988        }
5989        assert!(
5990            !graph
5991                .symbols
5992                .iter()
5993                .any(|symbol| symbol.name == overbound_unicode_name),
5994            "overbound Unicode identity was admitted"
5995        );
5996        assert!(
5997            graph.symbols.iter().all(|symbol| {
5998                symbol.name.chars().count() <= MAX_SNIPPET_CHARS
5999                    && !symbol.name.contains("Dictionary")
6000                    && !symbol.name.contains('=')
6001            }),
6002            "unnameable declaration or overbound identity leaked into the graph"
6003        );
6004        assert!(
6005            graph
6006                .symbols
6007                .iter()
6008                .any(|symbol| symbol.name == admitted_unicode_name
6009                    && symbol.name.len() == admitted_unicode_name.len()),
6010            "admitted Unicode identity was not preserved exactly"
6011        );
6012    }
6013
6014    #[test]
6015    fn invalid_parent_identity_detaches_valid_child() {
6016        let overbound_parent = "P".repeat(MAX_SNIPPET_CHARS + 1);
6017        let source = format!(
6018            "public class {overbound_parent} {{ public void Retained() {{}} }}\n\
6019             public class Valid {{ public void Sibling() {{}} }}\n"
6020        );
6021
6022        let graph = extract_symbol_graph("Parents.cs", Some("csharp"), &source);
6023
6024        assert!(
6025            !graph
6026                .symbols
6027                .iter()
6028                .any(|symbol| symbol.name == overbound_parent)
6029        );
6030        assert!(
6031            graph
6032                .symbols
6033                .iter()
6034                .any(|symbol| { symbol.name == "Retained" && symbol.parent.is_none() })
6035        );
6036        assert!(graph.symbols.iter().any(|symbol| {
6037            symbol.name == "Sibling" && symbol.parent.as_deref() == Some("Valid")
6038        }));
6039        assert!(!graph.relations.iter().any(|relation| {
6040            relation.kind == RelationKind::Contains && relation.target_name == "Retained"
6041        }));
6042    }
6043
6044    #[test]
6045    fn derived_qualified_scope_namespace_is_reserved_from_source_symbols() {
6046        let reserved = format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal");
6047        assert!(compact_symbol_identity(&reserved).is_none());
6048        assert_eq!(
6049            compact_symbol_identity("ordinary"),
6050            Some("ordinary".to_string())
6051        );
6052    }
6053
6054    #[test]
6055    fn extracts_remaining_specialized_language_basics() {
6056        let samples = [
6057            (
6058                "src/main.kt",
6059                "kotlin",
6060                r"
6061package com.example.atlas
6062
6063class Runner {
6064    fun run() {}
6065}
6066",
6067                SymbolKind::Class,
6068                "Runner",
6069            ),
6070            (
6071                "src/main.zig",
6072                "zig",
6073                r"
6074const Runner = struct {
6075    pub fn run(self: Runner) void {}
6076};
6077",
6078                SymbolKind::Function,
6079                "run",
6080            ),
6081            (
6082                "src/main.c",
6083                "c",
6084                r"
6085#include <stdio.h>
6086int run(void) { return 0; }
6087",
6088                SymbolKind::Function,
6089                "run",
6090            ),
6091            (
6092                "src/main.cpp",
6093                "cpp",
6094                r"
6095class Runner {
6096public:
6097    void run() {}
6098};
6099",
6100                SymbolKind::Class,
6101                "Runner",
6102            ),
6103            (
6104                "src/UserManager.m",
6105                "objective-c",
6106                r"
6107@interface UserManager
6108- (void)run;
6109@end
6110@implementation UserManager
6111- (void)run {}
6112@end
6113",
6114                SymbolKind::Class,
6115                "UserManager",
6116            ),
6117        ];
6118        for (path, language, source, kind, name) in samples {
6119            let graph = extract_symbol_graph(path, Some(language), source);
6120            assert!(
6121                graph
6122                    .symbols
6123                    .iter()
6124                    .any(|symbol| symbol.kind == kind && symbol.name.contains(name)),
6125                "expected {language} sample to contain {kind:?} {name}, got {:?}",
6126                graph.symbols
6127            );
6128        }
6129    }
6130
6131    #[test]
6132    fn normalizes_language_specific_edge_summaries() {
6133        let kotlin = extract_symbol_graph(
6134            "src/KotlinRunner.kt",
6135            Some("kotlin"),
6136            r"
6137package com.example.atlas
6138class KotlinRunner { fun run() { helper() } private fun helper() {} }
6139",
6140        );
6141        assert!(kotlin.symbols.iter().any(|symbol| {
6142            symbol.kind == SymbolKind::Module && symbol.name == "com.example.atlas"
6143        }));
6144        assert!(
6145            kotlin.symbols.iter().any(|symbol| {
6146                symbol.kind == SymbolKind::Class && symbol.name == "KotlinRunner"
6147            })
6148        );
6149        assert!(kotlin.symbols.iter().any(|symbol| {
6150            symbol.kind == SymbolKind::Method
6151                && symbol.name == "run"
6152                && symbol.parent.as_deref() == Some("KotlinRunner")
6153        }));
6154
6155        for path in ["src/Worker.kt", "scripts/tasks.kts"] {
6156            let ordinary_kotlin = extract_symbol_graph(
6157                path,
6158                Some("kotlin"),
6159                r#"
6160class Worker {
6161    fun queue(tasks: TaskContainer) {
6162        tasks.register("notGradleTask")
6163    }
6164}
6165"#,
6166            );
6167            assert!(
6168                !ordinary_kotlin.symbols.iter().any(|symbol| {
6169                    symbol.name == "notGradleTask"
6170                        || symbol.detail.as_deref() == Some("gradle-kotlin-dsl-task")
6171                }),
6172                "ordinary Kotlin path {path} should not emit Gradle task symbols: {:?}",
6173                ordinary_kotlin.symbols
6174            );
6175        }
6176
6177        let gradle_kotlin = extract_symbol_graph(
6178            "build.gradle.kts",
6179            Some("kotlin"),
6180            r#"
6181import org.springframework.boot.gradle.tasks.run.BootRun
6182
6183fun loadDotEnv() = emptyMap<String, String>()
6184
6185tasks.register<BootRun>("bootRunE2E") {
6186    group = "verification"
6187}
6188
6189val verifyAtlas by tasks.registering {
6190    group = "verification"
6191}
6192
6193tasks {
6194    register<Copy>("copyE2EReports") {
6195        group = "verification"
6196    }
6197}
6198
6199task("publishKtsE2E") {}
6200"#,
6201        );
6202        assert_eq!(gradle_kotlin.parser, ParserKind::TreeSitter);
6203        for task in [
6204            "bootRunE2E",
6205            "copyE2EReports",
6206            "verifyAtlas",
6207            "publishKtsE2E",
6208        ] {
6209            assert!(gradle_kotlin.symbols.iter().any(|symbol| {
6210                symbol.kind == SymbolKind::Function
6211                    && symbol.name == task
6212                    && symbol.detail.as_deref() == Some("gradle-kotlin-dsl-task")
6213            }));
6214        }
6215        let fallback_gradle_kotlin = extract_fallback_graph(
6216            "build.gradle.kts",
6217            Some("kotlin"),
6218            r#"
6219tasks.register<BootRun>("bootRunE2E") {
6220    group = "verification"
6221}
6222
6223fun broken(
6224"#,
6225        );
6226        assert_eq!(fallback_gradle_kotlin.parser, ParserKind::Fallback);
6227        assert!(
6228            fallback_gradle_kotlin.symbols.iter().any(|symbol| {
6229                symbol.kind == SymbolKind::Function
6230                    && symbol.name == "bootRunE2E"
6231                    && symbol.detail.as_deref() == Some("gradle-kotlin-dsl-task")
6232            }),
6233            "fallback Gradle KTS graph should retain task symbols: {:?}",
6234            fallback_gradle_kotlin.symbols
6235        );
6236
6237        let gradle_groovy = extract_symbol_graph(
6238            "build.gradle",
6239            Some("groovy"),
6240            r"
6241plugins { id 'java' }
6242
6243tasks.register('bootRunSmoke', BootRun) {
6244    group = 'verification'
6245}
6246
6247task cleanE2E(type: Delete) {}
6248
6249tasks {
6250    create('copyGroovyReports') {
6251        group = 'verification'
6252    }
6253}
6254
6255task('publishE2E') {}
6256",
6257        );
6258        assert_eq!(gradle_groovy.parser, ParserKind::Fallback);
6259        for task in [
6260            "bootRunSmoke",
6261            "cleanE2E",
6262            "copyGroovyReports",
6263            "publishE2E",
6264        ] {
6265            assert!(gradle_groovy.symbols.iter().any(|symbol| {
6266                symbol.kind == SymbolKind::Function
6267                    && symbol.name == task
6268                    && symbol.detail.as_deref() == Some("gradle-groovy-dsl-task")
6269            }));
6270        }
6271
6272        let zig = extract_symbol_graph(
6273            "src/runner.zig",
6274            Some("zig"),
6275            "const ZigRunner = struct { pub fn run(self: ZigRunner) void {} };\n",
6276        );
6277        assert!(
6278            zig.symbols
6279                .iter()
6280                .any(|symbol| { symbol.kind == SymbolKind::Struct && symbol.name == "ZigRunner" })
6281        );
6282        assert!(zig.symbols.iter().any(|symbol| {
6283            symbol.kind == SymbolKind::Method
6284                && symbol.name == "run"
6285                && symbol.parent.as_deref() == Some("ZigRunner")
6286        }));
6287        assert!(
6288            !zig.symbols
6289                .iter()
6290                .any(|symbol| symbol.name.contains("struct {"))
6291        );
6292
6293        let c_graph = extract_symbol_graph(
6294            "src/runner.c",
6295            Some("c"),
6296            "#include <stdio.h>\nint c_run(void) { return 0; }\n",
6297        );
6298        let c_run_count = c_graph
6299            .symbols
6300            .iter()
6301            .filter(|symbol| symbol.kind == SymbolKind::Function && symbol.name == "c_run")
6302            .count();
6303        assert_eq!(c_run_count, 1);
6304        assert!(
6305            c_graph
6306                .symbols
6307                .iter()
6308                .all(|symbol| symbol.documentation.as_deref() != Some("include <stdio.h>"))
6309        );
6310
6311        let cpp_graph = extract_symbol_graph(
6312            "src/runner.cpp",
6313            Some("cpp"),
6314            "class CppRunner { public: void run(); void inline_run() {} };\n",
6315        );
6316        let cpp_run_names = cpp_graph
6317            .symbols
6318            .iter()
6319            .filter(|symbol| symbol.parent.as_deref() == Some("CppRunner"))
6320            .map(|symbol| symbol.name.as_str())
6321            .collect::<Vec<_>>();
6322        assert_eq!(cpp_run_names, vec!["run", "inline_run"]);
6323        assert!(cpp_graph.symbols.iter().all(|symbol| {
6324            symbol.parent.as_deref() != Some("CppRunner") || symbol.kind == SymbolKind::Method
6325        }));
6326
6327        let objc_graph = extract_symbol_graph(
6328            "src/ObjRunner.m",
6329            Some("objective-c"),
6330            r"
6331@interface ObjRunner
6332- (void)run;
6333@end
6334@implementation ObjRunner
6335- (void)run {}
6336@end
6337",
6338        );
6339        assert_eq!(
6340            objc_graph
6341                .symbols
6342                .iter()
6343                .filter(|symbol| symbol.kind == SymbolKind::Class && symbol.name == "ObjRunner")
6344                .count(),
6345            1
6346        );
6347        assert_eq!(
6348            objc_graph
6349                .symbols
6350                .iter()
6351                .filter(|symbol| symbol.kind == SymbolKind::Method && symbol.name == "run")
6352                .count(),
6353            1
6354        );
6355        assert!(
6356            !objc_graph
6357                .symbols
6358                .iter()
6359                .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == "run")
6360        );
6361        assert!(objc_graph.symbols.iter().any(|symbol| {
6362            symbol.kind == SymbolKind::Method
6363                && symbol.name == "run"
6364                && symbol.signature.contains("run")
6365                && !symbol.signature.contains('{')
6366        }));
6367    }
6368
6369    #[test]
6370    fn extracts_cargo_manifest_symbols() {
6371        let source = r#"
6372[package]
6373name = "projectatlas"
6374
6375[dependencies]
6376tree-sitter = "0.26"
6377serde_json = { workspace = true }
6378serde_alias = { version = "1", package = "serde" }
6379
6380[target.'cfg(windows)'.dependencies]
6381windows-sys = "0.60"
6382"#;
6383        let graph = extract_symbol_graph("Cargo.toml", Some("cargo-manifest"), source);
6384        assert!(
6385            graph.symbols.iter().any(|symbol| {
6386                symbol.kind == SymbolKind::Package && symbol.name == "projectatlas"
6387            })
6388        );
6389        assert!(graph.symbols.iter().any(|symbol| {
6390            symbol.kind == SymbolKind::Dependency && symbol.name == "tree-sitter"
6391        }));
6392        assert!(
6393            graph
6394                .symbols
6395                .iter()
6396                .any(|symbol| { symbol.kind == SymbolKind::Dependency && symbol.name == "serde" })
6397        );
6398        assert!(graph.symbols.iter().any(|symbol| {
6399            symbol.kind == SymbolKind::Dependency && symbol.name == "windows-sys"
6400        }));
6401    }
6402
6403    #[test]
6404    fn cargo_lock_duplicate_package_names_keep_distinct_lines() {
6405        let source = r#"[[package]]
6406name = "windows-sys"
6407version = "0.59.0"
6408
6409[[package]]
6410name = "windows-sys"
6411version = "0.60.0"
6412"#;
6413        let graph = extract_symbol_graph("Cargo.lock", Some("cargo-lock"), source);
6414        let lines = graph
6415            .symbols
6416            .iter()
6417            .filter(|symbol| symbol.kind == SymbolKind::Dependency && symbol.name == "windows-sys")
6418            .map(|symbol| symbol.line_start)
6419            .collect::<Vec<_>>();
6420        assert_eq!(lines, vec![2, 6]);
6421    }
6422
6423    #[test]
6424    fn specialized_language_registry_covers_target_set() {
6425        for expected in [
6426            "rust",
6427            "python",
6428            "javascript",
6429            "typescript",
6430            "java",
6431            "kotlin",
6432            "csharp",
6433            "go",
6434            "objective-c",
6435            "zig",
6436            "php",
6437        ] {
6438            assert!(specialized_languages().contains(&expected));
6439        }
6440    }
6441
6442    #[test]
6443    fn extracts_php_symbols_relations_and_exact_selectors() {
6444        let shared_headers = "<?php class Fields {\n    public string\n        $name,\n        $other;\n    public const\n        FIRST = 1,\n        SECOND = 2;\n}";
6445        let fields = extract_symbol_graph("src/fields.php", Some("php"), shared_headers);
6446        for (name, header, end) in [
6447            ("name", "public string", "$name"),
6448            ("other", "public string", "$other"),
6449            ("FIRST", "public const", "FIRST = 1"),
6450            ("SECOND", "public const", "SECOND = 2"),
6451        ] {
6452            let symbol = fields.symbols.iter().find(|symbol| symbol.name == name);
6453            assert!(
6454                symbol.is_some(),
6455                "shared PHP declaration {name} should exist"
6456            );
6457            let Some(symbol) = symbol else { return };
6458            assert!(
6459                symbol.source_selector.is_some(),
6460                "PHP declaration should have an exact selector"
6461            );
6462            let Some(selector) = symbol.source_selector else {
6463                return;
6464            };
6465            assert_eq!(Some(selector.byte_start), shared_headers.find(header));
6466            assert_eq!(selector.column_start, 4);
6467            assert_eq!(
6468                symbol.line_start,
6469                if name.starts_with(char::is_uppercase) {
6470                    5
6471                } else {
6472                    2
6473                }
6474            );
6475            let slice = &shared_headers[selector.byte_start..selector.byte_end];
6476            assert!(
6477                slice.starts_with(header) && slice.ends_with(end),
6478                "{name}: {slice}"
6479            );
6480        }
6481        for directive in [
6482            "__halt_compiler();",
6483            "__HALT_COMPILER /* comment */ ( /* comment */ );",
6484        ] {
6485            for (prefix, suffix) in [
6486                ("", ""),
6487                ("namespace N;", ""),
6488                ("namespace N {", "}"),
6489                ("namespace {", "}"),
6490            ] {
6491                let source = format!(
6492                    "<?php {prefix} function real() {{}} {directive} function fake() {{ payload(); }} {suffix} function outside() {{}}"
6493                );
6494                let graph = extract_symbol_graph("src/archive.php", Some("php"), &source);
6495                assert!(
6496                    graph.symbols.iter().any(|symbol| symbol.name == "real")
6497                        && graph
6498                            .symbols
6499                            .iter()
6500                            .all(|symbol| !matches!(symbol.name.as_str(), "fake" | "outside"))
6501                        && graph.relations.iter().all(|relation| !matches!(
6502                            relation.target_name.as_str(),
6503                            "payload" | "__halt_compiler" | "__HALT_COMPILER"
6504                        )),
6505                    "{source}: {graph:?}"
6506                );
6507            }
6508        }
6509        for lookalike in [
6510            "// __halt_compiler();\n",
6511            "$text = '__halt_compiler();';",
6512            "$object->__halt_compiler();",
6513            "Archive::__halt_compiler();",
6514            "__halt_compiler(1);",
6515            "function nested() { __halt_compiler(); }",
6516        ] {
6517            let source = format!("<?php {lookalike} function retained() {{}}");
6518            let graph = extract_symbol_graph("src/not-archive.php", Some("php"), &source);
6519            assert!(
6520                graph.symbols.iter().any(|symbol| symbol.name == "retained"),
6521                "{source}: {graph:?}"
6522            );
6523        }
6524        for (source, parent) in [
6525            (
6526                "<?php namespace N; function outer() { function inner() {} class InnerType {} }",
6527                Some("N"),
6528            ),
6529            (
6530                "<?php namespace N { class OuterType { function outer() { function inner() {} class InnerType {} } } }",
6531                Some("N"),
6532            ),
6533            (
6534                "<?php function outer() { function inner() {} class InnerType {} }",
6535                None,
6536            ),
6537            (
6538                "<?php namespace { function outer() { function inner() {} class InnerType {} } }",
6539                None,
6540            ),
6541        ] {
6542            let graph = extract_symbol_graph("src/nested.php", Some("php"), source);
6543            for name in ["inner", "InnerType"] {
6544                let symbol = graph.symbols.iter().find(|symbol| symbol.name == name);
6545                assert_eq!(
6546                    symbol.map(|symbol| (symbol.parent.as_deref(), symbol.kind)),
6547                    Some((
6548                        parent,
6549                        if name == "inner" {
6550                            SymbolKind::Function
6551                        } else {
6552                            SymbolKind::Class
6553                        }
6554                    )),
6555                    "{source}: {name}"
6556                );
6557            }
6558            assert!(
6559                !graph
6560                    .relations
6561                    .iter()
6562                    .any(|relation| relation.kind == RelationKind::Contains
6563                        && relation.source_name == "outer"
6564                        && matches!(relation.target_name.as_str(), "inner" | "InnerType"))
6565            );
6566        }
6567        for source in [
6568            "<?xml(); function boot(): void {}",
6569            "HTML<?xml_parser(); function boot(): void {}",
6570            "<?xml (); function boot(): void {}",
6571        ] {
6572            let graph = extract_symbol_graph("src/short.php", Some("php"), source);
6573            assert!(
6574                graph.symbols.iter().any(|symbol| symbol.name == "boot"),
6575                "{source}: {graph:?}"
6576            );
6577            assert_eq!(graph.parser, ParserKind::TreeSitter);
6578        }
6579        for callback in [
6580            "function () { hidden(); }",
6581            "fn() => hidden()",
6582            "static function () { require 'hidden.php'; hidden(); }",
6583            "static fn() => hidden()",
6584            "function () { $nested = fn() => hidden(); }",
6585        ] {
6586            let source = format!("<?php function outer() {{ $callback = {callback}; visible(); }}");
6587            let graph = extract_symbol_graph("src/callback.php", Some("php"), &source);
6588            assert!(
6589                graph
6590                    .relations
6591                    .iter()
6592                    .all(|relation| relation.target_name != "hidden"),
6593                "{graph:?}"
6594            );
6595            assert!(graph.relations.iter().any(
6596                |relation| relation.source_name == "outer" && relation.target_name == "visible"
6597            ));
6598            assert_eq!(graph.parser, ParserKind::Fallback);
6599            assert!(
6600                graph
6601                    .relations
6602                    .iter()
6603                    .all(|relation| relation.kind != RelationKind::Imports)
6604            );
6605        }
6606        for prefix_length in [MAX_SNIPPET_CHARS + 1, 1_900_000] {
6607            let prefix = "N".repeat(prefix_length);
6608            let clauses = (0..2_000)
6609                .map(|index| format!("A{index}"))
6610                .collect::<Vec<_>>()
6611                .join(",");
6612            let source = format!("<?php use {prefix}\\{{{clauses}}}; function kept() {{}}");
6613            let graph = extract_symbol_graph("src/imports.php", Some("php"), &source);
6614            assert_eq!(graph.parser, ParserKind::Fallback);
6615            assert!(
6616                graph
6617                    .relations
6618                    .iter()
6619                    .all(|relation| relation.kind != RelationKind::Imports)
6620            );
6621            assert!(graph.symbols.iter().any(|symbol| symbol.name == "kept"));
6622        }
6623        for character in ["N", "界"] {
6624            let prefix = character.repeat(MAX_SNIPPET_CHARS - 2);
6625            let source = format!("<?php use {prefix}\\{{A, BB}}; function kept() {{}}");
6626            let graph = extract_symbol_graph("src/imports.php", Some("php"), &source);
6627            assert_eq!(graph.parser, ParserKind::Fallback);
6628            let imports: Vec<_> = graph
6629                .relations
6630                .iter()
6631                .filter(|relation| relation.kind == RelationKind::Imports)
6632                .collect();
6633            assert_eq!(imports.len(), 1);
6634            assert_eq!(imports[0].target_name, format!("{prefix}\\A"));
6635        }
6636        let enum_graph = extract_symbol_graph(
6637            "src/State.php",
6638            Some("php"),
6639            "<?php enum State { public function run(): void {} }",
6640        );
6641        assert_eq!(enum_graph.parser, ParserKind::TreeSitter);
6642        assert!(enum_graph.symbols.iter().any(|symbol| {
6643            symbol.name == "run"
6644                && symbol.kind == SymbolKind::Method
6645                && symbol.parent.as_deref() == Some("State")
6646        }));
6647        let source = r#"<?php
6648namespace Atlas\Domain;
6649use Vendor\Thing as ThingAlias;
6650require_once "bootstrap.php";
6651include $dynamic;
6652interface Contract {}
6653trait Auditable {}
6654enum State: string {
6655    case Ready = 'ready';
6656    public function state_label(): void {}
6657    public static function state_boot(): void {}
6658}
6659class Service {
6660    public const VERSION = 1;
6661    private string $name = 'service';
6662    public function run(string $value): string {
6663        helper();
6664        $this->save();
6665        Service::boot();
6666    }
6667}
6668function helper(string $value): void {}
6669"#;
6670        let graph = extract_symbol_graph("src/Service.php", Some("php"), source);
6671        assert_eq!(graph.parser, ParserKind::Fallback);
6672
6673        for name in [
6674            "Atlas\\Domain",
6675            "Contract",
6676            "Auditable",
6677            "State",
6678            "Ready",
6679            "Service",
6680            "VERSION",
6681            "name",
6682            "run",
6683            "helper",
6684        ] {
6685            assert!(
6686                graph.symbols.iter().any(|symbol| symbol.name == name),
6687                "missing PHP symbol {name}: {:?}",
6688                graph.symbols
6689            );
6690        }
6691        for (name, kind, parent) in [
6692            ("Contract", SymbolKind::Interface, Some("Atlas\\Domain")),
6693            ("Auditable", SymbolKind::Trait, Some("Atlas\\Domain")),
6694            ("State", SymbolKind::Enum, Some("Atlas\\Domain")),
6695            ("Ready", SymbolKind::Value, Some("State")),
6696            ("state_label", SymbolKind::Method, Some("State")),
6697            ("state_boot", SymbolKind::Method, Some("State")),
6698            ("Service", SymbolKind::Class, Some("Atlas\\Domain")),
6699            ("VERSION", SymbolKind::Value, Some("Service")),
6700            ("name", SymbolKind::Value, Some("Service")),
6701            ("run", SymbolKind::Method, Some("Service")),
6702            ("helper", SymbolKind::Function, Some("Atlas\\Domain")),
6703        ] {
6704            assert!(
6705                graph.symbols.iter().any(|symbol| {
6706                    symbol.name == name && symbol.kind == kind && symbol.parent.as_deref() == parent
6707                }),
6708                "missing PHP kind/parent for {name}: {:?}",
6709                graph.symbols
6710            );
6711        }
6712        assert!(
6713            source.contains("public function run"),
6714            "method start missing from PHP fixture"
6715        );
6716        let method_start = source.find("public function run").unwrap_or_default();
6717        assert!(
6718            source[method_start..].contains("\n    }\n"),
6719            "method end missing from PHP fixture"
6720        );
6721        let method_relative_end = source[method_start..].find("\n    }\n").unwrap_or_default();
6722        let method_end = method_start + method_relative_end + 6;
6723        assert!(
6724            graph.symbols.iter().any(|symbol| symbol.name == "run"),
6725            "method symbol missing from PHP graph"
6726        );
6727        let Some(method) = graph.symbols.iter().find(|symbol| symbol.name == "run") else {
6728            return;
6729        };
6730        assert_eq!(
6731            method.source_selector,
6732            Some(SymbolSourceSelector {
6733                byte_start: method_start,
6734                byte_end: method_end,
6735                column_start: 4,
6736                column_end: 5,
6737            })
6738        );
6739        assert!(method.signature.contains("public function run"));
6740        assert!(!method.signature.contains("helper"));
6741        assert!(
6742            graph.symbols.iter().any(|symbol| symbol.name == "name"),
6743            "property symbol missing from PHP graph"
6744        );
6745        let Some(property) = graph.symbols.iter().find(|symbol| symbol.name == "name") else {
6746            return;
6747        };
6748        assert!(property.signature.contains("private string"));
6749        assert!(!property.signature.contains("service"));
6750        assert!(graph.relations.iter().any(|relation| {
6751            relation.kind == RelationKind::Imports && relation.target_name == "Vendor\\Thing"
6752        }));
6753        assert!(graph.relations.iter().any(|relation| {
6754            relation.kind == RelationKind::Imports && relation.target_name == "bootstrap.php"
6755        }));
6756        assert!(graph.symbols.iter().any(|symbol| {
6757            symbol.kind == SymbolKind::Import && symbol.name == "Vendor\\Thing" && !symbol.exported
6758        }));
6759        for target in ["helper", "Service::boot"] {
6760            assert!(
6761                graph.relations.iter().any(|relation| {
6762                    relation.kind == RelationKind::Calls && relation.target_name == target
6763                }),
6764                "missing PHP call target {target}: {:?}",
6765                graph.relations
6766            );
6767        }
6768        assert!(graph.relations.iter().all(|relation| {
6769            relation.kind != RelationKind::Calls || relation.target_name != "save"
6770        }));
6771        assert!(
6772            graph
6773                .relations
6774                .iter()
6775                .all(|relation| relation.target_name != "$dynamic")
6776        );
6777
6778        let multiple = extract_symbol_graph(
6779            "src/Multiple.php",
6780            Some("php"),
6781            "<?php class Multiple { public string $first = 'one', $second = 'two'; const FIRST = 1, SECOND = 2; }",
6782        );
6783        for (name, signature) in [
6784            ("first", "public string $ first ="),
6785            ("second", "public string $ second ="),
6786            ("FIRST", "const FIRST ="),
6787            ("SECOND", "const SECOND ="),
6788        ] {
6789            assert!(
6790                multiple
6791                    .symbols
6792                    .iter()
6793                    .any(|symbol| symbol.name == name && symbol.signature == signature),
6794                "missing PHP element {name} with signature {signature}: {:?}",
6795                multiple.symbols
6796            );
6797        }
6798
6799        let braced = extract_symbol_graph(
6800            "src/Braced.php",
6801            Some("php"),
6802            "<?php namespace Atlas { class Service { public function run(): void {} } }",
6803        );
6804        assert!(braced.symbols.iter().any(|symbol| {
6805            symbol.name == "Service" && symbol.parent.as_deref() == Some("Atlas")
6806        }));
6807        assert!(
6808            braced.symbols.iter().any(|symbol| {
6809                symbol.name == "run" && symbol.parent.as_deref() == Some("Service")
6810            })
6811        );
6812
6813        let duplicates = extract_symbol_graph(
6814            "src/duplicates.php",
6815            Some("php"),
6816            "<?php function same(): void {} function same(): void {}",
6817        );
6818        assert_eq!(
6819            duplicates
6820                .symbols
6821                .iter()
6822                .filter(|symbol| symbol.name == "same" && symbol.kind == SymbolKind::Function)
6823                .count(),
6824            2,
6825            "duplicate PHP declarations must remain visible rather than being merged"
6826        );
6827    }
6828
6829    #[test]
6830    fn php_selectors_use_original_columns_after_multibyte_purpose_header() {
6831        let source = "/* Purpose: café */<?php function run(): void {}";
6832        let graph = extract_symbol_graph("src/Service.php", Some("php"), source);
6833        let function_start = source.find("function run");
6834        assert!(function_start.is_some(), "PHP function should be present");
6835        let Some(function_start) = function_start else {
6836            return;
6837        };
6838        let function_symbol = graph.symbols.iter().find(|symbol| symbol.name == "run");
6839        assert!(
6840            function_symbol.is_some(),
6841            "PHP function should be indexed: {graph:?}"
6842        );
6843        let Some(function_symbol) = function_symbol else {
6844            return;
6845        };
6846        let selector = function_symbol.source_selector;
6847        assert!(
6848            selector.is_some(),
6849            "PHP function selector should be present: {function_symbol:?}"
6850        );
6851        let Some(selector) = selector else { return };
6852        assert_eq!(selector.byte_start, function_start);
6853        assert_eq!(
6854            selector.column_start,
6855            source[..function_start].chars().count(),
6856            "selector column must use Unicode scalars from the original source"
6857        );
6858    }
6859
6860    #[test]
6861    fn php_selector_columns_stay_exact_at_file_and_symbol_limits() {
6862        let mut source = String::from("<?php\n/*");
6863        source.push_str(&"x".repeat(1_800_000));
6864        source.push_str("*/\n");
6865        for index in 0..3_999 {
6866            assert!(writeln!(source, "function f{index}() {{}}").is_ok());
6867        }
6868        source.push_str("/* café */ function last() {}");
6869        let graph = extract_symbol_graph("src/large.php", Some("php"), &source);
6870        assert_eq!(graph.symbols.len(), 4_000);
6871        let last = graph.symbols.iter().find(|symbol| symbol.name == "last");
6872        assert!(
6873            last.is_some(),
6874            "last admitted declaration must remain exact"
6875        );
6876        let Some(last) = last else { return };
6877        assert!(
6878            last.source_selector.is_some(),
6879            "last declaration selector is missing"
6880        );
6881        let Some(selector) = last.source_selector else {
6882            return;
6883        };
6884        assert_eq!(selector.column_start, "/* café */ ".chars().count());
6885        assert_eq!(
6886            selector.column_end,
6887            "/* café */ function last() {}".chars().count()
6888        );
6889        assert_eq!(
6890            &source[selector.byte_start..selector.byte_end],
6891            "function last() {}"
6892        );
6893    }
6894
6895    #[test]
6896    fn selector_normalization_preserves_scalar_boundaries_and_cancellation() {
6897        let mut graph = extract_symbol_graph("probe.php", Some("php"), "<?php function f() {}");
6898        // Normalization changes existing selectors, not language support or selector admission.
6899        graph.language = Some("rust".to_owned());
6900        graph.symbols = vec![graph.symbols[0].clone(); 3];
6901        let source = "α\r\nβγ";
6902        for (symbol, (start, end)) in graph.symbols.iter_mut().zip([(4, 8), (0, 4), (6, 6)]) {
6903            symbol.source_selector = Some(super::SymbolSourceSelector {
6904                byte_start: start,
6905                byte_end: end,
6906                column_start: usize::MAX,
6907                column_end: usize::MAX,
6908            });
6909        }
6910        let result =
6911            super::normalize_source_selector_columns(&mut graph, source, &mut || Ok::<_, ()>(()));
6912        assert_eq!(result, Ok(()));
6913        for (symbol, expected) in graph.symbols.iter().zip([(0, 2), (0, 0), (1, 1)]) {
6914            assert_eq!(
6915                symbol
6916                    .source_selector
6917                    .map(|span| (span.column_start, span.column_end)),
6918                Some(expected)
6919            );
6920        }
6921        for source in ["x".repeat(65_536), format!("{}é", "x".repeat(65_536))] {
6922            for stop in [1, 2, 3, 5] {
6923                let mut candidate = graph.clone();
6924                candidate.symbols = vec![candidate.symbols[0].clone(); 129];
6925                for symbol in &mut candidate.symbols {
6926                    symbol.source_selector = Some(super::SymbolSourceSelector {
6927                        byte_start: 0,
6928                        byte_end: source.len(),
6929                        column_start: 0,
6930                        column_end: source.len(),
6931                    });
6932                }
6933                let mut checks = 0;
6934                let result =
6935                    super::normalize_source_selector_columns(&mut candidate, &source, &mut || {
6936                        checks += 1;
6937                        if checks == stop {
6938                            Err("cancelled")
6939                        } else {
6940                            Ok(())
6941                        }
6942                    });
6943                assert_eq!(result, Err("cancelled"));
6944                assert_eq!(checks, stop);
6945            }
6946        }
6947        let source = format!("é{}", "x".repeat(65_536));
6948        let mut checks = 0;
6949        graph.symbols[0].source_selector = Some(super::SymbolSourceSelector {
6950            byte_start: 0,
6951            byte_end: source.len(),
6952            column_start: 0,
6953            column_end: source.len(),
6954        });
6955        let result = super::normalize_source_selector_columns(&mut graph, &source, &mut || {
6956            checks += 1;
6957            if checks == 20 {
6958                Err("cancelled")
6959            } else {
6960                Ok(())
6961            }
6962        });
6963        assert_eq!(result, Err("cancelled"));
6964        assert_eq!(
6965            checks, 20,
6966            "the Unicode source walk must remain cancellable"
6967        );
6968    }
6969
6970    #[test]
6971    fn php_constructor_promoted_properties_are_class_members() {
6972        let source = r"<?php
6973class Account {
6974    public function __construct(
6975        public readonly string $name,
6976        private int $id = 0,
6977    ) {}
6978}
6979";
6980        let graph = extract_symbol_graph("src/Account.php", Some("php"), source);
6981
6982        for (name, exported, signature) in [
6983            ("name", true, "public readonly string"),
6984            ("id", false, "private int"),
6985        ] {
6986            let symbol = graph
6987                .symbols
6988                .iter()
6989                .find(|symbol| symbol.name == name && symbol.kind == SymbolKind::Value);
6990            assert!(
6991                symbol.is_some(),
6992                "missing promoted property {name}: {graph:?}"
6993            );
6994            let Some(symbol) = symbol else { continue };
6995            assert_eq!(symbol.parent.as_deref(), Some("Account"));
6996            assert_eq!(symbol.exported, exported);
6997            assert!(symbol.signature.contains(signature));
6998            assert!(symbol.source_selector.is_some());
6999        }
7000        assert!(!graph.symbols.iter().any(|symbol| {
7001            symbol.name == "name" && symbol.parent.as_deref() == Some("__construct")
7002        }));
7003
7004        let trait_graph = extract_symbol_graph(
7005            "src/Contract.php",
7006            Some("php"),
7007            r"<?php
7008trait Contract {
7009    public function __construct(public int $version) {}
7010}
7011",
7012        );
7013        let promoted = trait_graph
7014            .symbols
7015            .iter()
7016            .find(|symbol| symbol.name == "version" && symbol.kind == SymbolKind::Value);
7017        assert_eq!(
7018            promoted.and_then(|symbol| symbol.parent.as_deref()),
7019            Some("Contract"),
7020            "trait-promoted properties must belong to the trait"
7021        );
7022        assert!(!trait_graph.symbols.iter().any(|symbol| {
7023            symbol.name == "version" && symbol.parent.as_deref() == Some("__construct")
7024        }));
7025    }
7026
7027    #[test]
7028    fn php_visibility_modifiers_control_exported_symbol_queries() {
7029        let source = r"<?php
7030class Service {
7031    final protected function guarded(): void {}
7032    static private string $cache;
7033    private(set) string $readablePrivateSet;
7034    protected(set) string $readableProtectedSet;
7035    public(set) string $readablePublicSet;
7036    private(set) protected string $privateSetWithProtectedRead;
7037    public static function exposed(): void {}
7038    function defaulted(): void {}
7039}
7040";
7041        let graph = extract_symbol_graph("src/Service.php", Some("php"), source);
7042
7043        for (name, exported) in [
7044            ("Service", true),
7045            ("guarded", false),
7046            ("cache", false),
7047            ("readablePrivateSet", true),
7048            ("readableProtectedSet", true),
7049            ("readablePublicSet", true),
7050            ("privateSetWithProtectedRead", false),
7051            ("exposed", true),
7052            ("defaulted", true),
7053        ] {
7054            assert!(
7055                graph.symbols.iter().any(|symbol| symbol.name == name),
7056                "missing PHP symbol {name}: {:?}",
7057                graph.symbols
7058            );
7059            let Some(symbol) = graph.symbols.iter().find(|symbol| symbol.name == name) else {
7060                continue;
7061            };
7062            assert_eq!(
7063                symbol.exported, exported,
7064                "unexpected exported state for {name}: {symbol:?}"
7065            );
7066            assert_eq!(symbol.parser, ParserKind::TreeSitter);
7067            assert!(
7068                symbol.source_selector.is_some(),
7069                "missing selector for {name}"
7070            );
7071        }
7072
7073        let exported_names = graph
7074            .symbols
7075            .iter()
7076            .filter(|symbol| symbol.exported)
7077            .map(|symbol| symbol.name.as_str())
7078            .collect::<Vec<_>>();
7079        assert!(exported_names.contains(&"exposed"));
7080        assert!(exported_names.contains(&"defaulted"));
7081        assert!(exported_names.contains(&"readablePrivateSet"));
7082        assert!(exported_names.contains(&"readableProtectedSet"));
7083        assert!(exported_names.contains(&"readablePublicSet"));
7084        assert!(!exported_names.contains(&"privateSetWithProtectedRead"));
7085        assert!(!exported_names.contains(&"guarded"));
7086        assert!(!exported_names.contains(&"cache"));
7087    }
7088
7089    #[test]
7090    fn php_relative_scope_calls_preserve_exact_targets_and_source_evidence() {
7091        let source = r"<?php
7092class Child extends Base {
7093    public function run(): void {
7094        self::local();
7095        parent::inherited();
7096        static::lateBound();
7097        $scope::dynamic();
7098    }
7099}
7100";
7101        let graph = extract_symbol_graph("src/Child.php", Some("php"), source);
7102        let calls = graph
7103            .relations
7104            .iter()
7105            .filter(|relation| relation.kind == RelationKind::Calls)
7106            .collect::<Vec<_>>();
7107
7108        assert_eq!(calls.len(), 3, "dynamic PHP scopes must remain unresolved");
7109        for (target, line) in [
7110            ("self::local", 4),
7111            ("parent::inherited", 5),
7112            ("static::lateBound", 6),
7113        ] {
7114            assert!(
7115                calls.iter().any(|relation| {
7116                    relation.target_name == target && relation.source_name == "run"
7117                }),
7118                "missing PHP call relation {target}: {calls:?}"
7119            );
7120            let Some(relation) = calls
7121                .iter()
7122                .find(|relation| relation.target_name == target && relation.source_name == "run")
7123            else {
7124                continue;
7125            };
7126            assert_eq!(relation.path, "src/Child.php");
7127            assert_eq!(relation.line, line);
7128            assert!(relation.context.contains(target));
7129        }
7130        assert!(calls.iter().all(|relation| {
7131            !relation.target_name.contains("dynamic") && !relation.target_name.contains("scope")
7132        }));
7133    }
7134
7135    #[test]
7136    fn php_dynamic_execution_is_not_published_as_a_call() {
7137        let source = r"<?php
7138function run(string $code): void {
7139    eval($code);
7140    $callable();
7141    helper();
7142}
7143";
7144        let graph = extract_symbol_graph("src/DynamicExecution.php", Some("php"), source);
7145        assert_eq!(graph.parser, ParserKind::Fallback);
7146        assert!(graph.symbols.iter().any(|symbol| symbol.name == "run"));
7147        let calls = graph
7148            .relations
7149            .iter()
7150            .filter(|relation| relation.kind == RelationKind::Calls)
7151            .collect::<Vec<_>>();
7152
7153        assert!(calls.iter().all(|relation| relation.target_name != "eval"));
7154        assert!(
7155            calls
7156                .iter()
7157                .all(|relation| relation.target_name != "$callable")
7158        );
7159        assert!(calls.iter().any(|relation| {
7160            relation.target_name == "helper"
7161                && relation.source_name == "run"
7162                && relation.path == "src/DynamicExecution.php"
7163                && relation.context.contains("helper()")
7164        }));
7165    }
7166
7167    #[test]
7168    fn php_dynamic_member_calls_are_omitted_and_mark_coverage_incomplete() {
7169        let source = r"<?php
7170function save(): void {}
7171class Service {
7172    public static function boot(): void {}
7173}
7174function run(object $object): void {
7175    $object->save();
7176    $object?->save();
7177    Service::boot();
7178}
7179";
7180        let graph = extract_symbol_graph("src/DynamicMember.php", Some("php"), source);
7181        assert_eq!(graph.parser, ParserKind::Fallback, "graph: {graph:?}");
7182        let calls = graph
7183            .relations
7184            .iter()
7185            .filter(|relation| relation.kind == RelationKind::Calls)
7186            .collect::<Vec<_>>();
7187
7188        assert!(calls.iter().all(|relation| relation.target_name != "save"));
7189        assert!(calls.iter().any(|relation| {
7190            relation.target_name == "Service::boot"
7191                && relation.source_name == "run"
7192                && relation.context.contains("Service::boot()")
7193        }));
7194    }
7195
7196    #[test]
7197    fn php_first_class_callable_acquisitions_are_complete_but_dynamic_calls_are_partial() {
7198        let acquisition = extract_symbol_graph(
7199            "src/CallableAcquisition.php",
7200            Some("php"),
7201            r"<?php
7202function capture(callable $callable, object $object): void {
7203    $callable(...);
7204    $object->save(...);
7205    helper();
7206}
7207",
7208        );
7209        assert_eq!(
7210            acquisition.parser,
7211            ParserKind::TreeSitter,
7212            "graph: {acquisition:?}"
7213        );
7214        assert!(
7215            acquisition
7216                .symbols
7217                .iter()
7218                .any(|symbol| symbol.name == "capture" && symbol.source_selector.is_some())
7219        );
7220        let calls = acquisition
7221            .relations
7222            .iter()
7223            .filter(|relation| relation.kind == RelationKind::Calls)
7224            .collect::<Vec<_>>();
7225        assert_eq!(
7226            calls.len(),
7227            1,
7228            "acquisitions must not emit calls: {calls:?}"
7229        );
7230        assert_eq!(calls[0].target_name, "helper");
7231        assert_eq!(calls[0].source_name, "capture");
7232
7233        let dynamic = extract_symbol_graph(
7234            "src/DynamicCall.php",
7235            Some("php"),
7236            r"<?php
7237function invoke(callable $callable, object $object): void {
7238    $callable();
7239    $object->save();
7240    helper();
7241}
7242",
7243        );
7244        assert_eq!(dynamic.parser, ParserKind::Fallback, "graph: {dynamic:?}");
7245        let calls = dynamic
7246            .relations
7247            .iter()
7248            .filter(|relation| relation.kind == RelationKind::Calls)
7249            .collect::<Vec<_>>();
7250        assert!(calls.iter().any(|relation| {
7251            relation.target_name == "helper" && relation.source_name == "invoke"
7252        }));
7253        assert!(calls.iter().all(|relation| {
7254            relation.target_name != "$callable" && relation.target_name != "save"
7255        }));
7256    }
7257
7258    #[test]
7259    fn php_complete_static_source_stays_tree_sitter() {
7260        let graph = extract_symbol_graph("fixture.php", Some("php"), "<?php function run() {}");
7261        assert_eq!(graph.parser, ParserKind::TreeSitter, "graph: {graph:?}");
7262        assert!(graph.symbols.iter().any(|symbol| symbol.name == "run"));
7263    }
7264
7265    #[test]
7266    fn php_callable_acquisition_and_import_targets_stay_precise() {
7267        let source = r#"<?php
7268use Vendor\One, Vendor\Two as TwoAlias;
7269use Vendor\Group\{First, Second as GroupAlias};
7270require 'vendor\\bootstrap.php';
7271require 'vendor\\it\'s.php';
7272require 'bootstrap.php';
7273require "bootstrap.php";
7274require "boot/$name.php";
7275require $dynamic;
7276require [];
7277function run(): void {
7278    foo(...);
7279    foo();
7280    foo(...$args);
7281    Service::boot(...);
7282    Service::boot();
7283    $object->save(...);
7284    $object->save();
7285}
7286"#;
7287        let graph = extract_symbol_graph("src/Callable.php", Some("php"), source);
7288
7289        let imports = graph
7290            .relations
7291            .iter()
7292            .filter(|relation| relation.kind == RelationKind::Imports)
7293            .collect::<Vec<_>>();
7294        for (target, lines) in [
7295            ("Vendor\\One", vec![2]),
7296            ("Vendor\\Two", vec![2]),
7297            ("Vendor\\Group\\First", vec![3]),
7298            ("Vendor\\Group\\Second", vec![3]),
7299            ("vendor\\bootstrap.php", vec![4]),
7300            ("vendor\\it's.php", vec![5]),
7301            ("bootstrap.php", vec![6, 7]),
7302        ] {
7303            assert_eq!(
7304                imports
7305                    .iter()
7306                    .filter(|relation| relation.target_name == target)
7307                    .count(),
7308                lines.len(),
7309                "missing exact PHP import target {target}: {imports:?}"
7310            );
7311            let mut observed_lines = imports
7312                .iter()
7313                .filter(|relation| relation.target_name == target)
7314                .map(|relation| {
7315                    assert_eq!(relation.path, "src/Callable.php");
7316                    let expected_context = if relation.line >= 4 {
7317                        source
7318                            .lines()
7319                            .nth(relation.line - 1)
7320                            .unwrap_or_default()
7321                            .trim_end_matches(';')
7322                    } else {
7323                        target
7324                    };
7325                    assert_eq!(relation.context, expected_context);
7326                    relation.line
7327                })
7328                .collect::<Vec<_>>();
7329            observed_lines.sort_unstable();
7330            assert_eq!(observed_lines, lines);
7331        }
7332        assert!(imports.iter().all(|relation| {
7333            !relation.target_name.contains("boot/")
7334                && !relation.target_name.contains("dynamic")
7335                && relation.target_name != "[]"
7336                && !relation.target_name.contains("TwoAlias")
7337                && !relation.target_name.contains("GroupAlias")
7338        }));
7339
7340        let calls = graph
7341            .relations
7342            .iter()
7343            .filter(|relation| relation.kind == RelationKind::Calls)
7344            .collect::<Vec<_>>();
7345        for (target, lines) in [("foo", vec![13, 14]), ("Service::boot", vec![16])] {
7346            assert_eq!(
7347                calls
7348                    .iter()
7349                    .filter(|relation| relation.target_name == target)
7350                    .count(),
7351                lines.len(),
7352                "callable acquisition must not be published as invocation for {target}: {calls:?}"
7353            );
7354            let mut observed_lines = calls
7355                .iter()
7356                .filter(|relation| relation.target_name == target)
7357                .map(|relation| {
7358                    assert_eq!(relation.source_name, "run");
7359                    assert_eq!(relation.path, "src/Callable.php");
7360                    assert!(relation.context.contains(target));
7361                    relation.line
7362                })
7363                .collect::<Vec<_>>();
7364            observed_lines.sort_unstable();
7365            assert_eq!(observed_lines, lines);
7366        }
7367        assert!(calls.iter().all(|relation| {
7368            !relation.context.contains("foo(...)")
7369                && !relation.context.contains("Service::boot(...)")
7370                && !relation.context.contains("$object->save(...)")
7371                && relation.target_name != "save"
7372                && relation.target_name != "$dynamic"
7373                && relation.target_name != "$name"
7374        }));
7375    }
7376
7377    #[test]
7378    fn php_static_include_literals_reject_constants_and_decode_double_quoted_escapes() {
7379        let source = r#"<?php
7380require "vendor\\bootstrap.php";
7381require "vendor\"quoted.php";
7382require "control\npath.php";
7383require "dollar\$name.php";
7384require "unsupported\x41.php";
7385require "boot/$name.php";
7386require $dynamic;
7387require BOOTSTRAP;
7388require Vendor\BOOTSTRAP;
7389require (1 + 2);
7390require 'dir  bootstrap.php';
7391"#;
7392        let graph = extract_symbol_graph("src/StaticIncludes.php", Some("php"), source);
7393        let imports = graph
7394            .relations
7395            .iter()
7396            .filter(|relation| relation.kind == RelationKind::Imports)
7397            .collect::<Vec<_>>();
7398
7399        for (target, line, context) in [
7400            (
7401                "vendor\\bootstrap.php",
7402                2,
7403                r#"require "vendor\\bootstrap.php""#,
7404            ),
7405            ("vendor\"quoted.php", 3, r#"require "vendor\"quoted.php""#),
7406            ("dollar$name.php", 5, r#"require "dollar\$name.php""#),
7407            ("dir  bootstrap.php", 12, "require 'dir bootstrap.php'"),
7408        ] {
7409            let matches = imports
7410                .iter()
7411                .filter(|relation| relation.target_name == target)
7412                .collect::<Vec<_>>();
7413            assert_eq!(
7414                matches.len(),
7415                1,
7416                "missing exact escaped PHP include {target}: {imports:?}"
7417            );
7418            let relation = matches[0];
7419            assert_eq!(relation.path, "src/StaticIncludes.php");
7420            assert_eq!(relation.line, line);
7421            assert_eq!(relation.context, context);
7422        }
7423        assert!(imports.iter().all(|relation| {
7424            relation.target_name != "control\npath.php"
7425                && !relation.target_name.chars().any(char::is_control)
7426        }));
7427        assert!(imports.iter().all(|relation| {
7428            !relation.target_name.contains("unsupported")
7429                && !relation.target_name.contains("boot/")
7430                && relation.target_name != "$dynamic"
7431                && relation.target_name != "BOOTSTRAP"
7432                && !relation.target_name.contains("Vendor")
7433                && !relation.target_name.contains("1 + 2")
7434        }));
7435    }
7436
7437    #[test]
7438    fn php_static_nowdoc_includes_decode_indentation_and_reject_dynamic_forms() {
7439        let graph = extract_symbol_graph(
7440            "src/NowdocIncludes.php",
7441            Some("php"),
7442            r"<?php
7443require <<<'PATH'
7444bootstrap.php
7445PATH;
7446require <<<'PATH'
7447    indented.php
7448    PATH;
7449",
7450        );
7451        assert_eq!(graph.parser, ParserKind::TreeSitter);
7452        let imports = graph
7453            .relations
7454            .iter()
7455            .filter(|relation| relation.kind == RelationKind::Imports)
7456            .collect::<Vec<_>>();
7457
7458        for (target, line) in [("bootstrap.php", 2), ("indented.php", 5)] {
7459            assert!(
7460                imports
7461                    .iter()
7462                    .any(|relation| relation.target_name == target && relation.line == line),
7463                "missing static nowdoc include {target}: {imports:?}"
7464            );
7465            if let Some(relation) = imports
7466                .iter()
7467                .find(|relation| relation.target_name == target && relation.line == line)
7468            {
7469                assert_eq!(relation.path, "src/NowdocIncludes.php");
7470                assert_eq!(relation.context, format!("require <<<'PATH' {target} PATH"));
7471                assert_eq!(relation.parser, ParserKind::TreeSitter);
7472            }
7473        }
7474
7475        let graph = extract_symbol_graph(
7476            "src/NowdocIncludes.php",
7477            Some("php"),
7478            r"<?php
7479require <<<'PATH'
7480first.php
7481second.php
7482PATH;
7483require <<<PATH
7484heredoc.php
7485PATH;
7486require $dynamic;
7487",
7488        );
7489        let imports = graph
7490            .relations
7491            .iter()
7492            .filter(|relation| relation.kind == RelationKind::Imports)
7493            .collect::<Vec<_>>();
7494
7495        assert!(imports.iter().all(|relation| {
7496            !matches!(
7497                relation.target_name.as_str(),
7498                "first.php\nsecond.php" | "heredoc.php" | "$dynamic"
7499            )
7500        }));
7501    }
7502
7503    #[test]
7504    fn php_call_targets_use_character_bounds_for_unicode_names() {
7505        let target = "é".repeat(MAX_SNIPPET_CHARS / 2 + 1);
7506        assert!(target.chars().count() <= MAX_SNIPPET_CHARS);
7507        assert!(target.len() > MAX_SNIPPET_CHARS);
7508        let source = format!("<?php\nfunction caller(): void {{\n{target}();\n}}\n");
7509        let graph = extract_symbol_graph("src/UnicodeCalls.php", Some("php"), &source);
7510        let calls = graph
7511            .relations
7512            .iter()
7513            .filter(|relation| relation.kind == RelationKind::Calls)
7514            .collect::<Vec<_>>();
7515        assert!(
7516            calls.iter().any(|relation| relation.target_name == target),
7517            "Unicode PHP call within character bound must be published: {calls:?}"
7518        );
7519        let Some(relation) = calls.iter().find(|relation| relation.target_name == target) else {
7520            return;
7521        };
7522        assert_eq!(relation.source_name, "caller");
7523        assert_eq!(relation.path, "src/UnicodeCalls.php");
7524        assert_eq!(relation.line, 3);
7525        assert_eq!(relation.context, format!("{target}()"));
7526    }
7527
7528    #[test]
7529    fn php_include_context_is_bounded_without_truncating_the_target() {
7530        let target = "é".repeat(MAX_SNIPPET_CHARS);
7531        let source = format!("<?php require /*{}*/ '{target}';", "comment ".repeat(1_000));
7532        let graph = extract_symbol_graph("src/IncludeContext.php", Some("php"), &source);
7533        let imports = graph
7534            .relations
7535            .iter()
7536            .filter(|relation| relation.kind == RelationKind::Imports)
7537            .collect::<Vec<_>>();
7538        assert_eq!(imports.len(), 1);
7539        assert_eq!(imports[0].target_name, target);
7540        assert!(imports[0].context.starts_with("require /*comment"));
7541        assert!(imports[0].context.chars().count() <= MAX_SNIPPET_CHARS);
7542    }
7543
7544    #[test]
7545    fn php_parenthesized_static_include_targets_stay_precise() {
7546        let source = r#"<?php
7547require(/*before*/'parenthesized.php'/*after*/);
7548include_once("parent-config.php");
7549require(('nested.php'));
7550require("malformed.php" + );
7551require("boot/$name.php");
7552require($dynamic);
7553require [];
7554require(BOOTSTRAP);
7555include_once(Vendor\BOOTSTRAP);
7556"#;
7557        let graph = extract_symbol_graph("src/Includes.php", Some("php"), source);
7558        let imports = graph
7559            .relations
7560            .iter()
7561            .filter(|relation| relation.kind == RelationKind::Imports)
7562            .collect::<Vec<_>>();
7563
7564        for (target, line) in [("parenthesized.php", 2), ("parent-config.php", 3)] {
7565            let matches = imports
7566                .iter()
7567                .filter(|relation| relation.target_name == target)
7568                .collect::<Vec<_>>();
7569            assert_eq!(
7570                matches.len(),
7571                1,
7572                "missing exact static include {target}: {imports:?}"
7573            );
7574            let relation = matches[0];
7575            assert_eq!(relation.path, "src/Includes.php");
7576            assert_eq!(relation.line, line);
7577            assert_eq!(
7578                relation.context,
7579                source
7580                    .lines()
7581                    .nth(line - 1)
7582                    .unwrap_or_default()
7583                    .trim_end_matches(';')
7584            );
7585        }
7586        assert!(imports.iter().all(|relation| {
7587            !matches!(
7588                relation.target_name.as_str(),
7589                "nested.php" | "malformed.php" | "boot/$name.php" | "$dynamic" | "[]" | "BOOTSTRAP"
7590            ) && !relation.target_name.contains("Vendor")
7591        }));
7592    }
7593
7594    #[test]
7595    fn php_trait_use_relations_preserve_type_ownership_and_ignore_adaptations() {
7596        let source = r"<?php
7597trait Auditable {
7598    public function audit(): void {}
7599}
7600trait FirstTrait {}
7601class Service {
7602    use Auditable;
7603    use FirstTrait, Vendor\SecondTrait {
7604        FirstTrait::audit insteadof Vendor\SecondTrait;
7605        Vendor\SecondTrait::audit as protected auditFromSecond;
7606    }
7607}
7608";
7609        let graph = extract_symbol_graph("src/Traits.php", Some("php"), source);
7610        let imports = graph
7611            .relations
7612            .iter()
7613            .filter(|relation| relation.kind == RelationKind::Imports)
7614            .collect::<Vec<_>>();
7615
7616        for target in ["Auditable", "FirstTrait", "Vendor\\SecondTrait"] {
7617            assert!(
7618                imports.iter().any(|relation| {
7619                    relation.source_name == "Service" && relation.target_name == target
7620                }),
7621                "missing class-owned PHP trait relation {target}: {imports:?}"
7622            );
7623        }
7624        assert!(graph.symbols.iter().any(|symbol| {
7625            symbol.kind == SymbolKind::Method
7626                && symbol.name == "audit"
7627                && symbol.parent.as_deref() == Some("Auditable")
7628        }));
7629        assert!(graph.relations.iter().any(|relation| {
7630            relation.kind == RelationKind::Contains
7631                && relation.source_name == "Auditable"
7632                && relation.target_name == "audit"
7633        }));
7634        assert!(imports.iter().all(|relation| {
7635            relation.source_name != "<module>"
7636                && !relation.target_name.starts_with("use ")
7637                && !relation.target_name.contains("audit")
7638                && !relation.target_name.contains("protected")
7639        }));
7640        assert!(!graph.symbols.iter().any(|symbol| {
7641            symbol.kind == SymbolKind::Import && symbol.parent.as_deref() == Some("Service")
7642        }));
7643
7644        let anonymous = extract_symbol_graph(
7645            "src/AnonymousTraits.php",
7646            Some("php"),
7647            "<?php new class { use Auditable; };",
7648        );
7649        assert!(
7650            anonymous
7651                .symbols
7652                .iter()
7653                .all(|symbol| symbol.kind != SymbolKind::Import),
7654            "anonymous-class trait use must not become a module import: {anonymous:?}"
7655        );
7656        assert!(
7657            anonymous
7658                .relations
7659                .iter()
7660                .all(|relation| relation.kind != RelationKind::Imports),
7661            "unsupported anonymous-class trait composition must abstain: {anonymous:?}"
7662        );
7663    }
7664
7665    #[test]
7666    fn php_namespace_context_preserves_semicolon_and_braced_ownership() {
7667        let source = r"<?php
7668namespace First;
7669use Vendor\First as FirstAlias;
7670class FirstService {}
7671function first_helper(): void {}
7672first_helper();
7673namespace Second;
7674class SecondService {}
7675function second_helper(): void {}
7676namespace Third { class BracedService {} }
7677namespace Fourth;
7678class FourthService {}
7679namespace { class GlobalService {} }
7680class OutsideGlobal {}
7681";
7682        let graph = extract_symbol_graph("src/Namespaces.php", Some("php"), source);
7683
7684        for (name, parent) in [
7685            ("Vendor\\First", "First"),
7686            ("FirstService", "First"),
7687            ("first_helper", "First"),
7688            ("SecondService", "Second"),
7689            ("second_helper", "Second"),
7690            ("BracedService", "Third"),
7691            ("FourthService", "Fourth"),
7692        ] {
7693            let symbol = graph.symbols.iter().find(|symbol| symbol.name == name);
7694            assert!(
7695                symbol.is_some(),
7696                "missing PHP symbol {name}: {:?}",
7697                graph.symbols
7698            );
7699            let Some(symbol) = symbol else { return };
7700            assert_eq!(
7701                symbol.parent.as_deref(),
7702                Some(parent),
7703                "wrong parent for {name}"
7704            );
7705            assert!(graph.relations.iter().any(|relation| {
7706                relation.kind == RelationKind::Contains
7707                    && relation.source_name == parent
7708                    && relation.target_name == name
7709            }));
7710        }
7711        assert!(graph.relations.iter().any(|relation| {
7712            relation.kind == RelationKind::Calls
7713                && relation.source_name == "First"
7714                && relation.target_name == "first_helper"
7715        }));
7716
7717        let global_symbol = graph
7718            .symbols
7719            .iter()
7720            .find(|symbol| symbol.name == "GlobalService");
7721        assert!(
7722            global_symbol.is_some(),
7723            "missing global PHP symbol: {:?}",
7724            graph.symbols
7725        );
7726        let Some(global_symbol) = global_symbol else {
7727            return;
7728        };
7729        assert!(global_symbol.parent.is_none());
7730        assert!(!graph.relations.iter().any(|relation| {
7731            relation.kind == RelationKind::Contains && relation.target_name == "GlobalService"
7732        }));
7733
7734        let outside_global = graph
7735            .symbols
7736            .iter()
7737            .find(|symbol| symbol.name == "OutsideGlobal");
7738        assert_eq!(
7739            outside_global.and_then(|symbol| symbol.parent.as_deref()),
7740            None
7741        );
7742
7743        let malformed = extract_symbol_graph(
7744            "src/MalformedNamespace.php",
7745            Some("php"),
7746            "<?php\nnamespace Before;\nclass BeforeService {}\nnamespace Broken\\;\nclass AfterMalformed {}\n",
7747        );
7748        assert!(malformed.symbols.iter().any(|symbol| {
7749            symbol.name == "BeforeService" && symbol.parent.as_deref() == Some("Before")
7750        }));
7751        assert!(
7752            !malformed
7753                .symbols
7754                .iter()
7755                .any(|symbol| symbol.name == "AfterMalformed")
7756        );
7757        assert_eq!(malformed.parser, ParserKind::Fallback);
7758        assert!(!malformed.relations.iter().any(|relation| {
7759            relation.kind == RelationKind::Contains && relation.target_name == "AfterMalformed"
7760        }));
7761    }
7762
7763    #[test]
7764    fn php_conditional_namespace_declarations_preserve_scope_without_crossing_symbol_owners() {
7765        let source = r"<?php
7766namespace Conditional;
7767if ($enabled) {
7768    function boot(): void {}
7769    class ConditionalService {}
7770}
7771class Owner {
7772    public function run(): void {
7773        if ($enabled) {
7774            function nested(): void {}
7775        }
7776    }
7777}
7778";
7779        let graph = extract_symbol_graph("src/Conditional.php", Some("php"), source);
7780
7781        for name in ["boot", "ConditionalService"] {
7782            let symbol = graph.symbols.iter().find(|symbol| symbol.name == name);
7783            assert!(symbol.is_some(), "missing conditional PHP symbol {name}");
7784            let Some(symbol) = symbol else { return };
7785            assert_eq!(symbol.parent.as_deref(), Some("Conditional"));
7786            assert!(graph.relations.iter().any(|relation| {
7787                relation.kind == RelationKind::Contains
7788                    && relation.source_name == "Conditional"
7789                    && relation.target_name == name
7790            }));
7791        }
7792
7793        for (name, parent) in [("run", "Owner"), ("nested", "Conditional")] {
7794            let symbol = graph.symbols.iter().find(|symbol| symbol.name == name);
7795            assert!(symbol.is_some(), "missing nested PHP symbol {name}");
7796            let Some(symbol) = symbol else { return };
7797            assert_eq!(symbol.parent.as_deref(), Some(parent));
7798            assert!(graph.relations.iter().any(|relation| {
7799                relation.kind == RelationKind::Contains
7800                    && relation.source_name == parent
7801                    && relation.target_name == name
7802            }));
7803        }
7804    }
7805
7806    #[test]
7807    fn php_mixed_recovery_dynamic_and_bounded_inputs_stay_conservative() {
7808        let mixed = extract_symbol_graph(
7809            "templates/page.php",
7810            Some("php"),
7811            "<main>static</main><?php function render(): void { helper(); } ?>",
7812        );
7813        assert_eq!(mixed.parser, ParserKind::TreeSitter);
7814        assert!(mixed.symbols.iter().any(|symbol| symbol.name == "render"));
7815
7816        let pure = extract_symbol_graph(
7817            "src/pure.php",
7818            Some("php"),
7819            "function pure(): void { return; }",
7820        );
7821        assert_eq!(pure.parser, ParserKind::TreeSitter);
7822        assert!(
7823            pure.symbols.is_empty(),
7824            "tagless PHP files are inline text, not PHP-only fragments: {pure:?}"
7825        );
7826
7827        let dynamic = extract_symbol_graph(
7828            "src/dynamic.php",
7829            Some("php"),
7830            "<?php $callable(); $object->$method(); include $path;",
7831        );
7832        assert_eq!(dynamic.parser, ParserKind::Fallback);
7833        assert!(dynamic.relations.iter().all(|relation| {
7834            !matches!(relation.kind, RelationKind::Calls | RelationKind::Imports)
7835                || ![
7836                    "$callable",
7837                    "$method",
7838                    "$path",
7839                    "callable",
7840                    "method",
7841                    "path",
7842                ]
7843                .contains(&relation.target_name.as_str())
7844        }));
7845
7846        let malformed = extract_symbol_graph(
7847            "src/broken.php",
7848            Some("php"),
7849            "<?php function recovered(): void {} function broken( { $unknown->();",
7850        );
7851        assert_eq!(malformed.parser, ParserKind::Fallback);
7852        assert!(
7853            malformed
7854                .symbols
7855                .iter()
7856                .any(|symbol| symbol.name == "recovered")
7857        );
7858        assert!(malformed.relations.iter().all(|relation| {
7859            relation.kind != RelationKind::Calls || relation.target_name != "$unknown"
7860        }));
7861        assert!(malformed.symbols.len() <= MAX_SYMBOLS_PER_FILE);
7862        assert!(malformed.relations.len() <= 8_000);
7863    }
7864
7865    #[test]
7866    fn php_namespace_context_builds_one_forward_cursor_at_intended_scale() {
7867        let declaration_count = 8_050;
7868        let source = large_semicolon_namespace_source(declaration_count);
7869        let mut parse_check = || Ok::<(), Infallible>(());
7870        let parsed = super::parse_php_tree(&source, &mut parse_check)
7871            .ok()
7872            .flatten();
7873        assert!(parsed.is_some(), "large PHP source should have a tree");
7874        let Some(parsed) = parsed else { return };
7875        let root = parsed.tree.root_node();
7876        let named_child_count = root.named_child_count();
7877        let mut context_check = || Ok::<(), Infallible>(());
7878        let mut context = PhpNamespaceContext::from_program(root, &source, &mut context_check)
7879            .expect("namespace context should build");
7880        let mut cursor = root.walk();
7881        let mut declaration_lookups = 0;
7882        for child in root.named_children(&mut cursor) {
7883            if matches!(child.kind(), "namespace_definition" | "php_tag") {
7884                continue;
7885            }
7886            declaration_lookups += 1;
7887            let expected_parent = if declaration_lookups == 1 {
7888                "Prefix"
7889            } else {
7890                "Scale"
7891            };
7892            assert_eq!(context.parent_for(child).as_deref(), Some(expected_parent));
7893        }
7894        assert_eq!(named_child_count, declaration_count + 3);
7895        assert_eq!(context.examined_children, named_child_count);
7896        assert_eq!(context.parent_lookups, declaration_lookups);
7897        assert_eq!(declaration_lookups, declaration_count);
7898        assert_eq!(context.next_range, context.ranges.len() - 1);
7899
7900        let bounded = extract_symbol_graph("src/large.php", Some("php"), &source);
7901        assert_eq!(bounded.parser, ParserKind::Fallback);
7902        assert_eq!(bounded.symbols.len(), MAX_SYMBOLS_PER_FILE);
7903        for count in [
7904            MAX_SYMBOLS_PER_FILE - 1,
7905            MAX_SYMBOLS_PER_FILE,
7906            MAX_SYMBOLS_PER_FILE + 1,
7907        ] {
7908            let mut source = String::from("<?php\n");
7909            for index in 0..count {
7910                assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
7911            }
7912            let graph = extract_symbol_graph("src/capped.php", Some("php"), &source);
7913            let expected = if count > MAX_SYMBOLS_PER_FILE {
7914                ParserKind::Fallback
7915            } else {
7916                ParserKind::TreeSitter
7917            };
7918            assert_eq!(graph.parser, expected, "PHP symbol-cap coverage at {count}");
7919            assert_eq!(graph.symbols.len(), count.min(MAX_SYMBOLS_PER_FILE));
7920            assert!(graph.symbols.iter().all(|symbol| symbol.parser == expected));
7921        }
7922    }
7923
7924    #[test]
7925    fn php_relation_caps_report_partial_coverage() {
7926        for length in [
7927            MAX_SNIPPET_CHARS - 1,
7928            MAX_SNIPPET_CHARS,
7929            MAX_SNIPPET_CHARS + 1,
7930        ] {
7931            let name = "N".repeat(length);
7932            for declaration in [
7933                format!("class {name} {{ function child() {{ helper(); }} }}"),
7934                format!("trait {name} {{ function child() {{ helper(); }} }}"),
7935                format!("interface {name} {{ function child(); }}"),
7936                format!("enum {name} {{ function child() {{ helper(); }} }}"),
7937                format!("function {name}() {{ function child() {{ helper(); }} }}"),
7938            ] {
7939                let source = format!("<?php {declaration} function kept() {{}}");
7940                let graph = extract_symbol_graph("src/declaration-name.php", Some("php"), &source);
7941                let rejected = length > MAX_SNIPPET_CHARS;
7942                let expected = if rejected {
7943                    ParserKind::Fallback
7944                } else {
7945                    ParserKind::TreeSitter
7946                };
7947                assert_eq!(
7948                    graph.parser, expected,
7949                    "PHP declaration admission: {declaration}"
7950                );
7951                assert!(graph.symbols.iter().any(|symbol| symbol.name == "kept"));
7952                assert_eq!(
7953                    graph.symbols.iter().any(|symbol| symbol.name == "child"),
7954                    !rejected
7955                );
7956                if rejected {
7957                    assert_eq!(graph.symbols.len(), 1);
7958                    assert!(graph.relations.is_empty());
7959                }
7960                assert!(graph.symbols.iter().all(|symbol| symbol.parser == expected));
7961                assert!(
7962                    graph
7963                        .relations
7964                        .iter()
7965                        .all(|relation| relation.parser == expected)
7966                );
7967            }
7968        }
7969        for length in [
7970            MAX_SNIPPET_CHARS - 1,
7971            MAX_SNIPPET_CHARS,
7972            MAX_SNIPPET_CHARS + 1,
7973        ] {
7974            let target = "T".repeat(length);
7975            let source = format!("<?php class Owner {{ use {target}, Kept; }}");
7976            let graph = extract_symbol_graph("src/trait-name.php", Some("php"), &source);
7977            let expected = if length > MAX_SNIPPET_CHARS {
7978                ParserKind::Fallback
7979            } else {
7980                ParserKind::TreeSitter
7981            };
7982            assert_eq!(
7983                graph.parser, expected,
7984                "PHP trait-name coverage at {length}"
7985            );
7986            assert!(
7987                graph
7988                    .relations
7989                    .iter()
7990                    .any(|relation| relation.kind == RelationKind::Imports
7991                        && relation.target_name == "Kept")
7992            );
7993            assert_eq!(
7994                graph
7995                    .relations
7996                    .iter()
7997                    .filter(|relation| relation.kind == RelationKind::Imports)
7998                    .count(),
7999                if length > MAX_SNIPPET_CHARS { 1 } else { 2 }
8000            );
8001            assert!(
8002                graph
8003                    .relations
8004                    .iter()
8005                    .all(|relation| relation.parser == expected)
8006            );
8007        }
8008        for count in [
8009            MAX_RELATIONS_PER_FILE - 1,
8010            MAX_RELATIONS_PER_FILE,
8011            MAX_RELATIONS_PER_FILE + 1,
8012        ] {
8013            let calls = format!("<?php\n{}", "helper();\n".repeat(count));
8014            let graph = extract_symbol_graph("src/calls.php", Some("php"), &calls);
8015            let expected = if count > MAX_RELATIONS_PER_FILE {
8016                ParserKind::Fallback
8017            } else {
8018                ParserKind::TreeSitter
8019            };
8020            assert_eq!(graph.parser, expected, "PHP call-cap coverage at {count}");
8021            assert_eq!(graph.relations.len(), count.min(MAX_RELATIONS_PER_FILE));
8022            assert!(
8023                graph
8024                    .relations
8025                    .iter()
8026                    .all(|relation| relation.parser == expected)
8027            );
8028        }
8029        let targets = (0..MAX_RELATIONS_PER_FILE)
8030            .map(|index| format!("T{index}"))
8031            .collect::<Vec<_>>()
8032            .join(", ");
8033        for source in [
8034            format!("<?php\nhelper();\nuse Vendor\\{{{targets}}};"),
8035            format!("<?php\nhelper();\nclass Owner {{ use {targets}; }}"),
8036            format!("<?php\nclass Owner {{ use {targets}, Overflow; }}"),
8037        ] {
8038            let graph = extract_symbol_graph("src/imports.php", Some("php"), &source);
8039            assert_eq!(graph.parser, ParserKind::Fallback);
8040            assert_eq!(graph.relations.len(), MAX_RELATIONS_PER_FILE);
8041            assert!(
8042                graph
8043                    .relations
8044                    .iter()
8045                    .all(|relation| relation.parser == ParserKind::Fallback)
8046            );
8047        }
8048    }
8049
8050    #[test]
8051    fn php_oversized_namespace_is_bounded_before_declaration_ownership() {
8052        let oversized_name = "N".repeat(1_900_000);
8053        let mut source = format!("<?php\nnamespace {oversized_name};\n");
8054        for index in 0..(MAX_SYMBOLS_PER_FILE + 50) {
8055            assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
8056        }
8057        let mut parse_check = || Ok::<(), Infallible>(());
8058        let parsed = super::parse_php_tree(&source, &mut parse_check)
8059            .ok()
8060            .flatten();
8061        assert!(parsed.is_some(), "oversized namespace source should parse");
8062        let Some(parsed) = parsed else { return };
8063
8064        let mut cancellation_checks = 0;
8065        let cancelled =
8066            PhpNamespaceContext::from_program(parsed.tree.root_node(), &source, &mut || {
8067                cancellation_checks += 1;
8068                if cancellation_checks > 128 {
8069                    Err("cancelled")
8070                } else {
8071                    Ok(())
8072                }
8073            });
8074        assert!(matches!(cancelled, Err("cancelled")));
8075        assert_eq!(cancellation_checks, 129);
8076
8077        let mut context_check = || Ok::<(), Infallible>(());
8078        let context =
8079            PhpNamespaceContext::from_program(parsed.tree.root_node(), &source, &mut context_check)
8080                .expect("oversized namespace context should remain bounded");
8081        assert_eq!(context.ranges.len(), 1);
8082        assert!(context.ranges[0].name.is_none());
8083
8084        let graph = extract_symbol_graph("src/OversizedNamespace.php", Some("php"), &source);
8085        assert_eq!(graph.parser, ParserKind::Fallback, "graph: {graph:?}");
8086        assert!(graph.symbols.is_empty());
8087        assert!(graph.relations.is_empty());
8088    }
8089
8090    #[test]
8091    fn php_unrepresentable_namespace_does_not_publish_global_facts() {
8092        let namespace = "N".repeat(MAX_SNIPPET_CHARS + 1);
8093        for source in [
8094            format!(
8095                "<?php\nnamespace {namespace};\nfunction hidden() {{ hidden(); }}\nnamespace Visible;\nfunction kept() {{}}"
8096            ),
8097            format!(
8098                "<?php\nnamespace {namespace} {{ function hidden() {{ hidden(); }} }}\nnamespace Visible {{ function kept() {{}} }}"
8099            ),
8100        ] {
8101            let graph = extract_symbol_graph("src/scopes.php", Some("php"), &source);
8102            assert_eq!(graph.parser, ParserKind::Fallback);
8103            assert!(graph.symbols.iter().all(|symbol| symbol.name != "hidden"));
8104            assert!(
8105                graph
8106                    .relations
8107                    .iter()
8108                    .all(|relation| relation.target_name != "hidden")
8109            );
8110            assert!(
8111                graph
8112                    .symbols
8113                    .iter()
8114                    .any(|symbol| symbol.name == "kept"
8115                        && symbol.parent.as_deref() == Some("Visible"))
8116            );
8117        }
8118    }
8119
8120    #[test]
8121    fn php_namespace_prepass_honors_cancellation_at_intended_scale() {
8122        let source = large_semicolon_namespace_source(8_050);
8123        let mut parse_check = || Ok::<(), Infallible>(());
8124        let parsed = super::parse_php_tree(&source, &mut parse_check)
8125            .ok()
8126            .flatten();
8127        assert!(parsed.is_some(), "large PHP source should have a tree");
8128        let Some(parsed) = parsed else { return };
8129        assert!(parsed.tree.root_node().named_child_count() > 8_000);
8130
8131        let mut checks = 0;
8132        let result =
8133            PhpNamespaceContext::from_program(parsed.tree.root_node(), &source, &mut || {
8134                checks += 1;
8135                if checks > 128 {
8136                    Err("cancelled")
8137                } else {
8138                    Ok(())
8139                }
8140            });
8141        assert!(matches!(result, Err("cancelled")));
8142        assert_eq!(checks, 129);
8143    }
8144
8145    #[test]
8146    fn php_opening_tag_detection_ignores_literals_and_comments() {
8147        for source in [
8148            r#"function marker(): string { return "<?"; }"#,
8149            r#"function marker(): string { return "<?php function fake(): void {}"; }"#,
8150            r"function marker(): string { return <<<TEXT
8151<?
8152TEXT;
8153}",
8154            r"function marker(): string { return <<<'TEXT'
8155<?
8156TEXT;
8157            }",
8158            "function marker(): string { return `echo <?`; }",
8159            "<?xml version=\"1.0\"?><root />",
8160            "HTML<?xml version=\"1.0\"?><root />",
8161            "<?xml\nversion = '1.0' ?><root />",
8162        ] {
8163            assert!(
8164                !super::contains_php_opening_tag(source),
8165                "PHP-only source was classified as mixed: {source:?}"
8166            );
8167            let graph = extract_symbol_graph("src/marker.php", Some("php"), source);
8168            assert!(
8169                graph.symbols.is_empty(),
8170                "tagless PHP source must remain inline text: {graph:?}"
8171            );
8172        }
8173
8174        for (source, symbol_name) in [
8175            ("<?php function tagged(): void {} ?>", "tagged"),
8176            ("<? function short_tagged(): void {} ?>", "short_tagged"),
8177            (
8178                "HTML // <? function short_after_output(): void {}",
8179                "short_after_output",
8180            ),
8181            (
8182                "//x<? function short_after_marker(): void {}",
8183                "short_after_marker",
8184            ),
8185            (
8186                "// <? function short_after_comment(): void {}",
8187                "short_after_comment",
8188            ),
8189            (
8190                "# <? function short_after_hash(): void {}",
8191                "short_after_hash",
8192            ),
8193            (
8194                "/* <? */ function short_after_block(): void {}",
8195                "short_after_block",
8196            ),
8197            (
8198                "<?= $value ?><?php function after_echo(): void {} ?>",
8199                "after_echo",
8200            ),
8201            (
8202                "<main>content</main><?php function mixed(): void {} ?>",
8203                "mixed",
8204            ),
8205            ("// <? ?><?php function reopened(): void {} ?>", "reopened"),
8206            (
8207                "# <? ?><?php function reopened_hash(): void {} ?>",
8208                "reopened_hash",
8209            ),
8210            (
8211                "/* <? ?> */<?php function reopened_block(): void {} ?>",
8212                "reopened_block",
8213            ),
8214            (
8215                "/* <?php */ function marker(): string { return 'marker'; }",
8216                "marker",
8217            ),
8218            ("// <?php function boot() {}", "boot"),
8219            ("# <?php function hash_boot() {}", "hash_boot"),
8220            ("/* <?php function block_boot(): void {}", "block_boot"),
8221            ("//x<?php function marker(): string {}", "marker"),
8222            (
8223                "#output<?= $value ?><?php function after_echo(): void {}",
8224                "after_echo",
8225            ),
8226        ] {
8227            assert!(
8228                super::contains_php_opening_tag(source),
8229                "genuine PHP opening tag was not classified as mixed: {source:?}"
8230            );
8231            let graph = extract_symbol_graph("src/tagged.php", Some("php"), source);
8232            assert!(
8233                graph
8234                    .symbols
8235                    .iter()
8236                    .any(|symbol| symbol.name == symbol_name),
8237                "mixed PHP symbol disappeared after opening-tag classification: {source:?}"
8238            );
8239        }
8240    }
8241
8242    #[test]
8243    fn php_pre_tag_inline_output_keeps_exact_symbol_identity() {
8244        let source = "// <?php function boot() {}";
8245        let graph = extract_symbol_graph("src/boot.php", Some("php"), source);
8246        assert!(
8247            graph.symbols.iter().any(|symbol| symbol.name == "boot"),
8248            "pre-tag PHP function must be recovered"
8249        );
8250        let Some(symbol) = graph.symbols.iter().find(|symbol| symbol.name == "boot") else {
8251            return;
8252        };
8253        assert!(
8254            symbol.source_selector.is_some(),
8255            "pre-tag PHP function must retain its selector"
8256        );
8257        let Some(selector) = symbol.source_selector else {
8258            return;
8259        };
8260        assert_eq!(
8261            &source[selector.byte_start..selector.byte_end],
8262            "function boot() {}"
8263        );
8264        assert_eq!(selector.column_start, 9);
8265        assert_eq!(selector.column_end, 27);
8266    }
8267
8268    #[test]
8269    fn php_mixed_inline_output_transitions_retain_tree_sitter_facts() {
8270        for (source, symbol_name, expected_selector, expected_start, expected_end) in [
8271            (
8272                "//x<?php function marker(): string { helper(); }",
8273                "marker",
8274                "function marker(): string { helper(); }",
8275                9,
8276                48,
8277            ),
8278            (
8279                "//x<?PHP function mixed_case(): string { helper(); }",
8280                "mixed_case",
8281                "function mixed_case(): string { helper(); }",
8282                9,
8283                52,
8284            ),
8285            (
8286                "HTML // <?php function marker(): string { helper(); }",
8287                "marker",
8288                "function marker(): string { helper(); }",
8289                14,
8290                53,
8291            ),
8292            (
8293                "HTML // <? function short_after_output(): string { helper(); }",
8294                "short_after_output",
8295                "function short_after_output(): string { helper(); }",
8296                11,
8297                62,
8298            ),
8299            (
8300                "//x<? function short_after_marker(): string { helper(); }",
8301                "short_after_marker",
8302                "function short_after_marker(): string { helper(); }",
8303                6,
8304                57,
8305            ),
8306            (
8307                "/* <?php function block_boot(): void { helper(); }",
8308                "block_boot",
8309                "function block_boot(): void { helper(); }",
8310                9,
8311                50,
8312            ),
8313            (
8314                "#output<?= $value ?><?php function after_echo(): void { helper(); }",
8315                "after_echo",
8316                "function after_echo(): void { helper(); }",
8317                26,
8318                67,
8319            ),
8320        ] {
8321            let graph = extract_symbol_graph("src/inline-output.php", Some("php"), source);
8322            assert_eq!(graph.parser, ParserKind::TreeSitter, "graph: {graph:?}");
8323            assert!(
8324                graph
8325                    .symbols
8326                    .iter()
8327                    .any(|symbol| symbol.name == symbol_name),
8328                "mixed PHP declaration must be recovered"
8329            );
8330            let Some(symbol) = graph
8331                .symbols
8332                .iter()
8333                .find(|symbol| symbol.name == symbol_name)
8334            else {
8335                return;
8336            };
8337            assert_eq!(symbol.parser, ParserKind::TreeSitter);
8338            assert!(
8339                symbol.source_selector.is_some(),
8340                "mixed PHP declaration must retain its selector"
8341            );
8342            let Some(selector) = symbol.source_selector else {
8343                return;
8344            };
8345            assert_eq!(selector.byte_start, expected_start);
8346            assert_eq!(selector.byte_end, expected_end);
8347            assert_eq!(selector.column_start, expected_start);
8348            assert_eq!(selector.column_end, expected_end);
8349            assert_eq!(
8350                &source[selector.byte_start..selector.byte_end],
8351                expected_selector
8352            );
8353            assert!(graph.relations.iter().any(|relation| {
8354                relation.kind == RelationKind::Calls
8355                    && relation.source_name == symbol_name
8356                    && relation.target_name == "helper"
8357                    && relation.path == "src/inline-output.php"
8358                    && relation.parser == ParserKind::TreeSitter
8359            }));
8360        }
8361    }
8362
8363    #[test]
8364    fn php_template_attribute_output_does_not_hide_php_tag() {
8365        let source = "<div title='<?php function boot(): void {} ?>'>";
8366        assert!(super::contains_php_opening_tag(source));
8367        let graph = extract_symbol_graph("src/template.php", Some("php"), source);
8368        assert_eq!(graph.parser, ParserKind::TreeSitter, "graph: {graph:?}");
8369        assert!(
8370            graph.symbols.iter().any(|symbol| symbol.name == "boot"),
8371            "PHP inside contiguous template output must remain navigable: {graph:?}"
8372        );
8373        assert!(
8374            graph
8375                .symbols
8376                .iter()
8377                .all(|symbol| { !matches!(symbol.name.as_str(), "div" | "title") })
8378        );
8379    }
8380
8381    #[test]
8382    fn php_confirmed_mode_does_not_promote_tag_like_literals() {
8383        let source = r#"<?php
8384//x<?php function fake_comment(): void {}
8385#output<?php function fake_hash_comment(): void {}
8386$value = "<?php function fake_string(): void {}";
8387$doc = <<<TEXT
8388<?php function fake_heredoc(): void {}
8389TEXT;
8390function real(): void { helper(); }
8391"#;
8392        let graph = extract_symbol_graph("src/confirmed-mode.php", Some("php"), source);
8393        assert_eq!(graph.parser, ParserKind::TreeSitter, "graph: {graph:?}");
8394        assert!(graph.symbols.iter().any(|symbol| symbol.name == "real"));
8395        assert!(graph.symbols.iter().all(|symbol| {
8396            !matches!(
8397                symbol.name.as_str(),
8398                "fake_comment" | "fake_hash_comment" | "fake_string" | "fake_heredoc"
8399            )
8400        }));
8401        assert!(graph.relations.iter().any(|relation| {
8402            relation.kind == RelationKind::Calls
8403                && relation.source_name == "real"
8404                && relation.target_name == "helper"
8405        }));
8406    }
8407
8408    #[test]
8409    fn php_opening_tag_search_stops_at_first_source_order_tag() {
8410        let mut source = String::from("<?php\n");
8411        for index in 0..512 {
8412            assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
8413        }
8414        let language = super::tree_sitter_language("php");
8415        assert!(language.is_some(), "PHP grammar should be registered");
8416        let Some(language) = language else { return };
8417        let mut parse_check = || Ok::<(), Infallible>(());
8418        let tree = super::parse_tree_sitter_language(&language, &source, &mut parse_check)
8419            .ok()
8420            .flatten();
8421        assert!(tree.is_some(), "mixed PHP source should produce a tree");
8422        let Some(tree) = tree else { return };
8423        let mut examined_nodes = 0;
8424        let first = super::first_php_tag_start(
8425            tree.root_node(),
8426            &mut || Ok::<(), Infallible>(()),
8427            &mut examined_nodes,
8428        );
8429        assert_eq!(first, Ok(Some(0)));
8430        assert!(
8431            examined_nodes < 16,
8432            "source-order tag search should stop before walking the function body: {examined_nodes}"
8433        );
8434    }
8435
8436    #[test]
8437    fn php_opening_tag_search_checks_cancellation_on_large_tagless_tree() {
8438        let mut source = String::new();
8439        for index in 0..512 {
8440            assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
8441        }
8442        let language = super::tree_sitter_language("php");
8443        assert!(language.is_some(), "PHP grammar should be registered");
8444        let Some(language) = language else { return };
8445        let mut parse_check = || Ok::<(), Infallible>(());
8446        let tree = super::parse_tree_sitter_language(&language, &source, &mut parse_check)
8447            .ok()
8448            .flatten();
8449        assert!(tree.is_some(), "PHP-only source should produce a tree");
8450        let Some(tree) = tree else { return };
8451        let mut examined_nodes = 0;
8452        let mut checks = 0;
8453        let result = super::first_php_tag_start(
8454            tree.root_node(),
8455            &mut || {
8456                checks += 1;
8457                Err::<(), _>("cancelled")
8458            },
8459            &mut examined_nodes,
8460        );
8461        assert_eq!(result, Err("cancelled"));
8462        assert_eq!(checks, 1);
8463        assert!(examined_nodes >= super::PARSER_CONTROL_CHECK_INTERVAL);
8464    }
8465
8466    #[test]
8467    fn php_opaque_range_walk_checks_cancellation_on_large_tree() {
8468        let mut source = String::new();
8469        for index in 0..512 {
8470            assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
8471        }
8472        let language = super::tree_sitter_language("php");
8473        assert!(language.is_some(), "PHP grammar should be registered");
8474        let Some(language) = language else { return };
8475        let mut parse_check = || Ok::<(), Infallible>(());
8476        let tree = super::parse_tree_sitter_language(&language, &source, &mut parse_check)
8477            .ok()
8478            .flatten();
8479        assert!(tree.is_some(), "PHP-only source should produce a tree");
8480        let Some(tree) = tree else { return };
8481        let mut ranges = Vec::new();
8482        let mut examined_nodes = 0;
8483        let mut checks = 0;
8484        let result = super::collect_php_only_opaque_ranges(
8485            tree.root_node(),
8486            &mut ranges,
8487            &mut || {
8488                checks += 1;
8489                Err::<(), _>("cancelled")
8490            },
8491            &mut examined_nodes,
8492        );
8493        assert_eq!(result, Err("cancelled"));
8494        assert_eq!(checks, 1);
8495        assert!(examined_nodes >= super::PARSER_CONTROL_CHECK_INTERVAL);
8496    }
8497
8498    #[test]
8499    fn php_mixed_tag_walk_checks_cancellation_on_large_tree() {
8500        let mut source = String::from("<?php\n");
8501        for index in 0..512 {
8502            assert!(writeln!(source, "function function_{index}(): void {{}}").is_ok());
8503        }
8504        let language = tree_sitter_php::LANGUAGE_PHP.into();
8505        let mut parse_check = || Ok::<(), Infallible>(());
8506        let tree = super::parse_tree_sitter_language(&language, &source, &mut parse_check)
8507            .ok()
8508            .flatten();
8509        assert!(tree.is_some(), "mixed PHP source should produce a tree");
8510        let Some(tree) = tree else { return };
8511        let mut next_opaque = 0;
8512        let mut examined_nodes = 0;
8513        let mut checks = 0;
8514        let result = super::tree_contains_php_tag_outside_ranges(
8515            tree.root_node(),
8516            &[(0, 6, true)],
8517            &mut next_opaque,
8518            &source,
8519            &mut || {
8520                checks += 1;
8521                Err::<(), _>("cancelled")
8522            },
8523            &mut examined_nodes,
8524        );
8525        assert_eq!(result, Err("cancelled"));
8526        assert_eq!(checks, 1);
8527        assert!(examined_nodes >= super::PARSER_CONTROL_CHECK_INTERVAL);
8528    }
8529
8530    #[test]
8531    fn indexes_composer_style_php_source_through_the_builtin_owner() {
8532        let source = r"<?php
8533namespace Composer\Autoload;
8534
8535use Composer\Autoload\ClassLoader as Loader;
8536
8537class ClassLoader {
8538    public function loadClass(string $class): bool {
8539        return $this->findFile($class) !== null;
8540    }
8541    private function findFile(string $class): ?string { return null; }
8542}
8543";
8544        let graph = extract_symbol_graph("vendor/composer/ClassLoader.php", Some("php"), source);
8545        assert_eq!(graph.parser, ParserKind::Fallback);
8546        assert!(
8547            graph
8548                .symbols
8549                .iter()
8550                .any(|symbol| { symbol.kind == SymbolKind::Class && symbol.name == "ClassLoader" })
8551        );
8552        assert!(graph.symbols.iter().any(|symbol| {
8553            symbol.kind == SymbolKind::Method
8554                && symbol.name == "loadClass"
8555                && symbol.parent.as_deref() == Some("ClassLoader")
8556        }));
8557        assert!(graph.relations.iter().any(|relation| {
8558            relation.kind == RelationKind::Imports
8559                && relation.target_name == "Composer\\Autoload\\ClassLoader"
8560        }));
8561        assert!(graph.relations.iter().all(|relation| {
8562            relation.kind != RelationKind::Calls || relation.target_name != "findFile"
8563        }));
8564    }
8565
8566    #[test]
8567    fn php_anonymous_class_members_do_not_inherit_named_owners() {
8568        let source = r"<?php
8569namespace Outer;
8570
8571trait Auditable {}
8572
8573function factory(): object {
8574    return new class {
8575        use Auditable;
8576        public string $anonymous_property;
8577        public const ANONYMOUS_CONSTANT = 1;
8578        public function __construct(public string $anonymous_promoted) {}
8579        public function anonymous_method(): void { anonymous_helper(); }
8580    };
8581}
8582
8583class NamedOwner {
8584    public string $named_property;
8585    public const NAMED_CONSTANT = 1;
8586    public function named_method(): void { named_helper(); }
8587    public function make(): object {
8588        return new class {
8589            use Auditable;
8590            public string $nested_property;
8591            public const NESTED_CONSTANT = 1;
8592            public function __construct(public string $nested_promoted) {}
8593            public function nested_method(): void { nested_helper(); }
8594        };
8595    }
8596}
8597";
8598        let graph = extract_symbol_graph("src/AnonymousMembers.php", Some("php"), source);
8599        assert_eq!(graph.parser, ParserKind::Fallback, "graph: {graph:?}");
8600
8601        for name in [
8602            "factory",
8603            "NamedOwner",
8604            "named_property",
8605            "NAMED_CONSTANT",
8606            "named_method",
8607        ] {
8608            assert!(
8609                graph.symbols.iter().any(|symbol| symbol.name == name),
8610                "named PHP symbol disappeared: {name}: {graph:?}"
8611            );
8612        }
8613        assert!(graph.symbols.iter().any(|symbol| {
8614            symbol.name == "factory" && symbol.parent.as_deref() == Some("Outer")
8615        }));
8616        assert!(graph.symbols.iter().any(|symbol| {
8617            symbol.name == "named_method" && symbol.parent.as_deref() == Some("NamedOwner")
8618        }));
8619
8620        for name in [
8621            "anonymous_property",
8622            "ANONYMOUS_CONSTANT",
8623            "anonymous_promoted",
8624            "anonymous_method",
8625            "nested_property",
8626            "NESTED_CONSTANT",
8627            "nested_promoted",
8628            "nested_method",
8629        ] {
8630            assert!(
8631                graph.symbols.iter().all(|symbol| symbol.name != name),
8632                "unsupported anonymous PHP member leaked as a symbol: {name}: {graph:?}"
8633            );
8634            assert!(
8635                graph.relations.iter().all(|relation| {
8636                    relation.source_name != name && relation.target_name != name
8637                })
8638            );
8639        }
8640        assert!(graph.relations.iter().any(|relation| {
8641            relation.kind == RelationKind::Calls
8642                && relation.source_name == "named_method"
8643                && relation.target_name == "named_helper"
8644        }));
8645        assert!(graph.relations.iter().all(|relation| {
8646            !matches!(
8647                relation.target_name.as_str(),
8648                "anonymous_helper" | "nested_helper"
8649            )
8650        }));
8651        assert!(!graph.relations.iter().any(|relation| {
8652            relation.kind == RelationKind::Imports && relation.target_name == "Auditable"
8653        }));
8654    }
8655}