Skip to main content

projectatlas_symbols/
lib.rs

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