projectatlas_symbols/
lib.rs

1//! Purpose: Extract tree-sitter-backed `ProjectAtlas` symbol graphs.
2
3mod configured_modules;
4mod languages;
5mod resolution_keys;
6mod semantic;
7
8pub use configured_modules::{
9    ConfiguredModuleError, ConfiguredModuleResolution, EcmaScriptConfigKind,
10    EcmaScriptModuleConfig, EcmaScriptPathMapping, MAX_CONFIGURED_MODULE_CONFIGS,
11    MAX_CONFIGURED_MODULE_IDENTITY_BYTES, MAX_CONFIGURED_MODULE_MAPPINGS,
12    MAX_CONFIGURED_MODULE_TARGETS,
13};
14pub use resolution_keys::{
15    ImportReference, ImportSyntax, MAX_RESOLUTION_KEYS_PER_FACT, RelationResolutionKeys,
16    ResolutionKeyProjection, ResolutionProjectionContext, ResolutionProjectionError,
17    SEMANTIC_RESOLUTION_CONTRACT_VERSION, SymbolResolutionKeys, derive_resolution_keys,
18    derive_resolution_keys_with_context, module_aliases_for_path, parse_import_references,
19    resolve_relative_import_path, semantic_resolution_contract_digest, source_stems_for_path,
20};
21
22use projectatlas_core::language::{
23    EmbeddedHostKind, EmbeddedLanguageCapability, SymbolParserOwner, TreeSitterGrammar,
24    builtin_tree_sitter_language_ids, language_capability, tree_sitter_grammar,
25};
26use projectatlas_core::symbols::{
27    CodeSymbol, ParserKind, RelationKind, SymbolGraph, SymbolKind, SymbolRelation,
28};
29use projectatlas_core::{IndexWorkControl, IndexWorkFailure, IndexWorkStage};
30use regex::Regex;
31use std::borrow::Cow;
32use std::collections::BTreeSet;
33use std::convert::Infallible;
34use std::ops::ControlFlow;
35use std::path::Path;
36use toml::Value as TomlValue;
37use tree_sitter::{Language, Node, ParseOptions, Parser};
38
39/// Maximum symbols kept from one file to bound large generated sources.
40const MAX_SYMBOLS_PER_FILE: usize = 4_000;
41/// Maximum relations kept from one file to bound call-heavy sources.
42const MAX_RELATIONS_PER_FILE: usize = 8_000;
43/// Maximum text length stored for signatures and relation context.
44const MAX_SNIPPET_CHARS: usize = 240;
45/// Maximum text length stored for extracted documentation.
46const MAX_DOC_CHARS: usize = 500;
47/// Maximum parsed rows between cooperative cancellation/deadline checks.
48const PARSER_CONTROL_CHECK_INTERVAL: usize = 128;
49
50/// Extract a symbol graph from source or manifest content.
51#[must_use]
52pub fn extract_symbol_graph(path: &str, language: Option<&str>, content: &str) -> SymbolGraph {
53    match extract_symbol_graph_checked(path, language, content, &mut || Ok::<(), Infallible>(())) {
54        Ok(graph) => graph,
55        Err(unreachable) => match unreachable {},
56    }
57}
58
59/// Extract a symbol graph while observing the shared indexing cancellation boundary.
60///
61/// # Errors
62///
63/// Returns a typed cancellation or deadline failure without returning a partial graph.
64pub fn extract_symbol_graph_controlled(
65    path: &str,
66    language: Option<&str>,
67    content: &str,
68    control: &IndexWorkControl,
69) -> Result<SymbolGraph, IndexWorkFailure> {
70    extract_symbol_graph_checked(path, language, content, &mut || {
71        control.check(IndexWorkStage::SymbolParsing)
72    })
73}
74
75/// Extract a symbol graph with one cooperative work checkpoint shared by every parser stage.
76fn extract_symbol_graph_checked<E>(
77    path: &str,
78    language: Option<&str>,
79    content: &str,
80    check: &mut impl FnMut() -> Result<(), E>,
81) -> Result<SymbolGraph, E> {
82    check()?;
83    let parse_content = content_without_leading_purpose_header(content);
84    if let Some(capability) = semantic::embedded_source::host_capability(path, language) {
85        return extract_embedded_host_graph_checked(
86            path,
87            language,
88            parse_content.as_ref(),
89            capability,
90            check,
91        );
92    }
93    match symbol_parser_owner(path, language) {
94        SymbolParserOwner::CargoManifest => {
95            return extract_cargo_manifest_graph_checked(path, language, content, check);
96        }
97        SymbolParserOwner::Vue => {
98            return extract_vue_sfc_graph_checked(path, language, parse_content.as_ref(), check);
99        }
100        SymbolParserOwner::PowerShell => {
101            return extract_powershell_graph_checked(path, language, parse_content.as_ref(), check);
102        }
103        SymbolParserOwner::Unavailable => {
104            check()?;
105            return Ok(empty_graph(path, language, ParserKind::Structural));
106        }
107        SymbolParserOwner::TreeSitter(_) | SymbolParserOwner::Fallback => {}
108    }
109    if let Some(parsed) = extract_tree_sitter_graph(path, language, parse_content.as_ref(), check)?
110    {
111        if !parsed.graph.symbols.is_empty() || !parsed.graph.relations.is_empty() {
112            check()?;
113            return Ok(parsed.graph);
114        }
115        if parsed.had_errors {
116            let fallback =
117                extract_fallback_graph_checked(path, language, parse_content.as_ref(), check)?;
118            if !fallback.symbols.is_empty() || !fallback.relations.is_empty() {
119                check()?;
120                return Ok(fallback);
121            }
122        }
123        check()?;
124        return Ok(parsed.graph);
125    }
126    extract_fallback_graph_checked(path, language, parse_content.as_ref(), check)
127}
128
129/// Extract accepted inline script facts without changing their host-file positions.
130fn extract_embedded_host_graph_checked<E>(
131    path: &str,
132    language: Option<&str>,
133    content: &str,
134    capability: EmbeddedLanguageCapability,
135    check: &mut impl FnMut() -> Result<(), E>,
136) -> Result<SymbolGraph, E> {
137    let mut graph = match capability.host_kind {
138        EmbeddedHostKind::HtmlLike => empty_graph(path, language, ParserKind::Structural),
139        EmbeddedHostKind::Component => {
140            extract_vue_sfc_graph_checked(path, language, content, check)?
141        }
142        EmbeddedHostKind::Template => {
143            extract_fallback_graph_checked(path, language, content, check)?
144        }
145    };
146    let (projections, _incomplete) = semantic::embedded_source::project(content).into_parts();
147    // Embedded hosts retain their structural/fallback graph parser. Runtime
148    // coverage therefore remains partial even when admitted tree-sitter facts
149    // are merged, including when reconciliation stopped after a safe prefix.
150    for projection in projections {
151        check()?;
152        if let Some(parsed) = extract_tree_sitter_graph(
153            path,
154            Some(projection.language().as_str()),
155            projection.source(),
156            check,
157        )? {
158            merge_missing_graph_entries_checked(
159                &mut graph,
160                parsed.graph,
161                capability.host_kind,
162                check,
163            )?;
164        }
165    }
166    check()?;
167    Ok(graph)
168}
169
170/// Check cooperative parser control at a bounded row interval.
171pub(crate) fn check_parser_iteration<E>(
172    iteration: usize,
173    check: &mut impl FnMut() -> Result<(), E>,
174) -> Result<(), E> {
175    if iteration.is_multiple_of(PARSER_CONTROL_CHECK_INTERVAL) {
176        check()?;
177    }
178    Ok(())
179}
180
181/// Return whether the language has a specialized tree-sitter parser.
182#[must_use]
183pub fn has_specialized_parser(language: &str) -> bool {
184    tree_sitter_grammar(language).is_some()
185}
186
187/// Return all specialized parser language identifiers.
188#[must_use]
189pub fn specialized_languages() -> &'static [&'static str] {
190    builtin_tree_sitter_language_ids()
191}
192
193/// Select the accepted parser owner, falling back to legacy path inference only without a language.
194fn symbol_parser_owner(path: &str, language: Option<&str>) -> SymbolParserOwner {
195    if let Some(language) = language {
196        return language_capability(language).map_or(SymbolParserOwner::Fallback, |capability| {
197            capability.symbol_parser
198        });
199    }
200    let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
201    if matches!(file_name, "Cargo.toml" | "Cargo.lock") {
202        return SymbolParserOwner::CargoManifest;
203    }
204    match Path::new(path)
205        .extension()
206        .and_then(|extension| extension.to_str())
207    {
208        Some(extension) if extension.eq_ignore_ascii_case("vue") => SymbolParserOwner::Vue,
209        Some(extension)
210            if ["ps1", "psm1", "psd1"]
211                .iter()
212                .any(|expected| extension.eq_ignore_ascii_case(expected)) =>
213        {
214            SymbolParserOwner::PowerShell
215        }
216        _ => SymbolParserOwner::Fallback,
217    }
218}
219
220/// Extract Vue SFC Composition API bindings with cooperative parser control.
221fn extract_vue_sfc_graph_checked<E>(
222    path: &str,
223    language: Option<&str>,
224    content: &str,
225    check: &mut impl FnMut() -> Result<(), E>,
226) -> Result<SymbolGraph, E> {
227    let mut graph = extract_fallback_graph_checked(path, language, content, check)?;
228    graph.parser = ParserKind::Structural;
229    let mut structural = empty_graph(path, language, ParserKind::Structural);
230    for (line_index, line) in content.lines().enumerate() {
231        check_parser_iteration(line_index, check)?;
232        let trimmed = line.trim();
233        if let Some(name) = vue_composition_binding_name(trimmed) {
234            push_symbol(
235                &mut structural,
236                &name,
237                SymbolKind::Value,
238                line_index + 1,
239                line_index + 1,
240                None,
241                Some("vue-composition-binding"),
242                trimmed,
243            );
244        }
245        if is_fallback_import(trimmed) {
246            push_relation(
247                &mut structural,
248                "<module>",
249                trimmed,
250                RelationKind::Imports,
251                line_index + 1,
252                trimmed,
253            );
254        }
255    }
256    merge_preferred_graph_entries_checked(&mut graph, structural, check)?;
257    check()?;
258    Ok(graph)
259}
260
261/// Extract `PowerShell` declarations with cooperative parser control.
262fn extract_powershell_graph_checked<E>(
263    path: &str,
264    language: Option<&str>,
265    content: &str,
266    check: &mut impl FnMut() -> Result<(), E>,
267) -> Result<SymbolGraph, E> {
268    let mut graph = extract_fallback_graph_checked(path, language, content, check)?;
269    graph.parser = ParserKind::Structural;
270    let mut structural = empty_graph(path, language, ParserKind::Structural);
271    for (line_index, line) in content.lines().enumerate() {
272        check_parser_iteration(line_index, check)?;
273        let trimmed = line.trim();
274        if let Some(name) = powershell_function_name(trimmed) {
275            push_symbol(
276                &mut structural,
277                &name,
278                SymbolKind::Function,
279                line_index + 1,
280                line_index + 1,
281                None,
282                Some("powershell-function"),
283                trimmed,
284            );
285        }
286        if let Some(name) = powershell_class_name(trimmed) {
287            push_symbol(
288                &mut structural,
289                &name,
290                SymbolKind::Class,
291                line_index + 1,
292                line_index + 1,
293                None,
294                Some("powershell-class"),
295                trimmed,
296            );
297        }
298        if is_fallback_import(trimmed) {
299            push_relation(
300                &mut structural,
301                "<module>",
302                trimmed,
303                RelationKind::Imports,
304                line_index + 1,
305                trimmed,
306            );
307        }
308    }
309    merge_preferred_graph_entries_checked(&mut graph, structural, check)?;
310    check()?;
311    Ok(graph)
312}
313
314/// Extract one `PowerShell` function declaration name.
315fn powershell_function_name(line: &str) -> Option<String> {
316    let mut parts = line.split_whitespace();
317    if !parts.next()?.eq_ignore_ascii_case("function") {
318        return None;
319    }
320    let raw_name = parts.next()?;
321    let name = raw_name.split(['(', '{']).next().unwrap_or_default().trim();
322    let name = name.rsplit_once(':').map_or(name, |(_, scoped)| scoped);
323    let valid = !name.is_empty()
324        && name
325            .chars()
326            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'));
327    valid.then(|| name.to_string())
328}
329
330/// Extract one `PowerShell` class declaration name.
331fn powershell_class_name(line: &str) -> Option<String> {
332    let mut parts = line.split_whitespace();
333    if !parts.next()?.eq_ignore_ascii_case("class") {
334        return None;
335    }
336    let raw_name = parts.next()?;
337    let name = raw_name
338        .split([':', '{', '('])
339        .next()
340        .unwrap_or_default()
341        .trim();
342    let valid = !name.is_empty()
343        && name
344            .chars()
345            .all(|character| character.is_ascii_alphanumeric() || character == '_');
346    valid.then(|| name.to_string())
347}
348
349/// Merge preferred graph entries with cooperative parser control.
350fn merge_preferred_graph_entries_checked<E>(
351    graph: &mut SymbolGraph,
352    preferred: SymbolGraph,
353    check: &mut impl FnMut() -> Result<(), E>,
354) -> Result<(), E> {
355    for (iteration, symbol) in preferred.symbols.into_iter().enumerate() {
356        check_parser_iteration(iteration, check)?;
357        if let Some(existing) = graph
358            .symbols
359            .iter()
360            .position(|existing| same_symbol_identity(existing, &symbol))
361        {
362            graph.symbols[existing] = symbol;
363        } else if graph.symbols.len() < MAX_SYMBOLS_PER_FILE {
364            graph.symbols.push(symbol);
365        }
366    }
367    for (iteration, relation) in preferred.relations.into_iter().enumerate() {
368        check_parser_iteration(iteration, check)?;
369        if let Some(existing) = graph
370            .relations
371            .iter()
372            .position(|existing| same_relation_identity(existing, &relation))
373        {
374            graph.relations[existing] = relation;
375        } else if graph.relations.len() < MAX_RELATIONS_PER_FILE {
376            graph.relations.push(relation);
377        }
378    }
379    Ok(())
380}
381
382/// Merge embedded facts without replacing compatibility facts owned by the host parser.
383fn merge_missing_graph_entries_checked<E>(
384    graph: &mut SymbolGraph,
385    embedded: SymbolGraph,
386    host_kind: EmbeddedHostKind,
387    check: &mut impl FnMut() -> Result<(), E>,
388) -> Result<(), E> {
389    let mut symbol_identities = graph
390        .symbols
391        .iter()
392        .map(|symbol| {
393            (
394                symbol.name.clone(),
395                symbol.kind as u8,
396                symbol.line_start,
397                symbol.line_end,
398                symbol.parent.clone(),
399            )
400        })
401        .collect::<BTreeSet<_>>();
402    for (iteration, symbol) in embedded.symbols.into_iter().enumerate() {
403        check_parser_iteration(iteration, check)?;
404        if graph.symbols.len() >= MAX_SYMBOLS_PER_FILE {
405            break;
406        }
407        if host_kind == EmbeddedHostKind::Component
408            && (symbol.kind == SymbolKind::Import
409                || (symbol.kind == SymbolKind::Value && !symbol.exported))
410        {
411            continue;
412        }
413        let identity = (
414            symbol.name.clone(),
415            symbol.kind as u8,
416            symbol.line_start,
417            symbol.line_end,
418            symbol.parent.clone(),
419        );
420        if symbol_identities.insert(identity) {
421            graph.symbols.push(symbol);
422        }
423    }
424    let mut relation_identities = graph
425        .relations
426        .iter()
427        .map(|relation| {
428            (
429                relation.source_name.clone(),
430                relation.target_name.clone(),
431                relation.kind as u8,
432                relation.line,
433            )
434        })
435        .collect::<BTreeSet<_>>();
436    for (iteration, relation) in embedded.relations.into_iter().enumerate() {
437        check_parser_iteration(iteration, check)?;
438        if graph.relations.len() >= MAX_RELATIONS_PER_FILE {
439            break;
440        }
441        let identity = (
442            relation.source_name.clone(),
443            relation.target_name.clone(),
444            relation.kind as u8,
445            relation.line,
446        );
447        if relation_identities.insert(identity) {
448            graph.relations.push(relation);
449        }
450    }
451    Ok(())
452}
453
454/// Return whether two symbols represent the same declaration.
455fn same_symbol_identity(left: &CodeSymbol, right: &CodeSymbol) -> bool {
456    left.name == right.name
457        && left.kind == right.kind
458        && left.line_start == right.line_start
459        && left.line_end == right.line_end
460        && left.parent == right.parent
461}
462
463/// Return whether two relations represent the same source edge.
464fn same_relation_identity(left: &SymbolRelation, right: &SymbolRelation) -> bool {
465    left.source_name == right.source_name
466        && left.target_name == right.target_name
467        && left.kind == right.kind
468        && left.line == right.line
469}
470
471/// Extract a Composition API binding name from a script setup row.
472fn vue_composition_binding_name(line: &str) -> Option<String> {
473    const MACROS: &[&str] = &[
474        "defineProps",
475        "defineEmits",
476        "defineModel",
477        "defineSlots",
478        "computed",
479        "ref",
480        "shallowRef",
481        "reactive",
482        "toRef",
483        "toRefs",
484        "watch",
485    ];
486    let rest = line
487        .strip_prefix("const ")
488        .or_else(|| line.strip_prefix("let "))
489        .or_else(|| line.strip_prefix("var "))?;
490    let (name, initializer) = rest.split_once('=')?;
491    let name = name.trim();
492    if name.is_empty() {
493        return None;
494    }
495    let initializer = initializer.trim_start();
496    MACROS
497        .iter()
498        .any(|macro_name| vue_initializer_starts_with_macro(initializer, macro_name))
499        .then(|| name.to_string())
500}
501
502/// Return whether a Vue initializer starts with a supported Composition API macro.
503fn vue_initializer_starts_with_macro(initializer: &str, macro_name: &str) -> bool {
504    vue_initializer_is_macro_call(initializer, macro_name)
505        || initializer
506            .strip_prefix("withDefaults(")
507            .is_some_and(|nested| vue_initializer_is_macro_call(nested.trim_start(), macro_name))
508}
509
510/// Return whether an initializer begins with the named macro call.
511fn vue_initializer_is_macro_call(initializer: &str, macro_name: &str) -> bool {
512    let Some(rest) = initializer.strip_prefix(macro_name) else {
513        return false;
514    };
515    let rest = rest.trim_start();
516    rest.starts_with('(') || rest.starts_with('<')
517}
518
519/// Extract Cargo package, workspace, and dependency entries with cooperative parser control.
520fn extract_cargo_manifest_graph_checked<E>(
521    path: &str,
522    language: Option<&str>,
523    content: &str,
524    check: &mut impl FnMut() -> Result<(), E>,
525) -> Result<SymbolGraph, E> {
526    check()?;
527    let mut graph = empty_graph(path, language, ParserKind::Manifest);
528    let is_lock = match language {
529        Some(language) => language == "cargo-lock",
530        None => path.ends_with("Cargo.lock"),
531    };
532    if is_lock {
533        extract_cargo_lock_packages_checked(&mut graph, content, check)?;
534        check()?;
535        return Ok(graph);
536    }
537    extract_cargo_toml_entries_checked(&mut graph, content, check)?;
538    check()?;
539    Ok(graph)
540}
541
542/// Extract package names from Cargo.lock with cooperative parser control.
543fn extract_cargo_lock_packages_checked<E>(
544    graph: &mut SymbolGraph,
545    content: &str,
546    check: &mut impl FnMut() -> Result<(), E>,
547) -> Result<(), E> {
548    let Ok(lockfile) = content.parse::<TomlValue>() else {
549        return Ok(());
550    };
551    check()?;
552    let Some(packages) = lockfile.get("package").and_then(TomlValue::as_array) else {
553        return Ok(());
554    };
555    let mut next_package_line = 0;
556    for (iteration, package) in packages.iter().enumerate() {
557        check_parser_iteration(iteration, check)?;
558        let Some(name) = package
559            .as_table()
560            .and_then(|table| table.get("name"))
561            .and_then(TomlValue::as_str)
562        else {
563            continue;
564        };
565        let line = cargo_lock_name_line_checked(content, name, next_package_line, check)?;
566        if let Some(found_line) = line {
567            next_package_line = found_line;
568        }
569        let line = line.unwrap_or(1);
570        push_symbol(
571            graph,
572            name,
573            SymbolKind::Dependency,
574            line,
575            line,
576            None,
577            Some("cargo-lock-package"),
578            &format!("lock package {name}"),
579        );
580    }
581    Ok(())
582}
583
584/// Return the one-based source line for a package name with cooperative parser control.
585fn cargo_lock_name_line_checked<E>(
586    content: &str,
587    package_name: &str,
588    start_line: usize,
589    check: &mut impl FnMut() -> Result<(), E>,
590) -> Result<Option<usize>, E> {
591    let mut in_package = false;
592    for (iteration, (index, raw_line)) in content.lines().enumerate().skip(start_line).enumerate() {
593        check_parser_iteration(iteration, check)?;
594        let line = raw_line.trim();
595        if line == "[[package]]" {
596            in_package = true;
597            continue;
598        }
599        if line.starts_with('[') {
600            in_package = false;
601        }
602        if in_package
603            && let Some((key, value)) = line.split_once('=')
604            && key.trim() == "name"
605            && value.trim().trim_matches('"') == package_name
606        {
607            return Ok(Some(index + 1));
608        }
609    }
610    Ok(None)
611}
612
613/// Extract package, workspace, and dependencies from Cargo.toml with cooperative parser control.
614fn extract_cargo_toml_entries_checked<E>(
615    graph: &mut SymbolGraph,
616    content: &str,
617    check: &mut impl FnMut() -> Result<(), E>,
618) -> Result<(), E> {
619    let Ok(manifest) = content.parse::<TomlValue>() else {
620        return Ok(());
621    };
622    check()?;
623    let Some(root) = manifest.as_table() else {
624        return Ok(());
625    };
626    let line_index = CargoTomlLineIndex::new_checked(content, check)?;
627    if root.contains_key("workspace") {
628        let line = line_index.section_line("workspace").unwrap_or(1);
629        push_symbol(
630            graph,
631            "workspace",
632            SymbolKind::Workspace,
633            line,
634            line,
635            None,
636            Some("cargo-workspace"),
637            line_index.line_text(line).unwrap_or("[workspace]"),
638        );
639    }
640    if let Some(package) = root.get("package").and_then(TomlValue::as_table)
641        && let Some(name) = package.get("name").and_then(TomlValue::as_str)
642    {
643        let line = line_index.key_line("package", "name").unwrap_or(1);
644        push_symbol(
645            graph,
646            name,
647            SymbolKind::Package,
648            line,
649            line,
650            None,
651            Some("cargo-package"),
652            line_index.line_text(line).unwrap_or(name),
653        );
654    }
655    collect_cargo_dependencies_checked(graph, &line_index, &[], root, check)?;
656    Ok(())
657}
658
659/// Recursively collect dependency tables from parsed Cargo TOML with cooperative control.
660fn collect_cargo_dependencies_checked<E>(
661    graph: &mut SymbolGraph,
662    line_index: &CargoTomlLineIndex,
663    path: &[String],
664    table: &toml::map::Map<String, TomlValue>,
665    check: &mut impl FnMut() -> Result<(), E>,
666) -> Result<(), E> {
667    check()?;
668    let section = path.join(".");
669    if is_dependency_table_path(path) {
670        for (iteration, (name, value)) in table.iter().enumerate() {
671            check_parser_iteration(iteration, check)?;
672            let line = line_index
673                .key_line(&section, name)
674                .or_else(|| line_index.section_line(&section))
675                .unwrap_or(1);
676            let detail = line_index
677                .line_text(line)
678                .map_or_else(|| name.as_str(), str::trim);
679            let dependency_name = manifest_dependency_name(name, value);
680            push_symbol(
681                graph,
682                &dependency_name,
683                SymbolKind::Dependency,
684                line,
685                line,
686                Some(section.clone()),
687                Some("cargo-dependency"),
688                detail,
689            );
690            push_relation(
691                graph,
692                "cargo",
693                &dependency_name,
694                RelationKind::DependsOn,
695                line,
696                detail,
697            );
698        }
699        return Ok(());
700    }
701    for (iteration, (key, value)) in table.iter().enumerate() {
702        check_parser_iteration(iteration, check)?;
703        let Some(child) = value.as_table() else {
704            continue;
705        };
706        let mut child_path = path.to_owned();
707        child_path.push(key.clone());
708        collect_cargo_dependencies_checked(graph, line_index, &child_path, child, check)?;
709    }
710    Ok(())
711}
712
713/// Return whether a parsed TOML table path declares dependencies.
714fn is_dependency_table_path(path: &[String]) -> bool {
715    path.last().is_some_and(|last| {
716        last == "dependencies" || last == "dev-dependencies" || last == "build-dependencies"
717    })
718}
719
720/// Return the Cargo dependency package name for normal or renamed dependencies.
721fn manifest_dependency_name(key: &str, value: &TomlValue) -> String {
722    value
723        .as_table()
724        .and_then(|table| table.get("package"))
725        .and_then(TomlValue::as_str)
726        .unwrap_or(key)
727        .to_string()
728}
729
730/// Source-line lookup for parsed Cargo TOML entries.
731struct CargoTomlLineIndex<'a> {
732    /// Original lines.
733    lines: Vec<&'a str>,
734    /// Section declaration lines keyed by dotted path.
735    sections: std::collections::HashMap<String, usize>,
736    /// Key declaration lines keyed by dotted section and key name.
737    keys: std::collections::HashMap<(String, String), usize>,
738}
739
740impl<'a> CargoTomlLineIndex<'a> {
741    /// Build a line index for TOML source positions with cooperative parser control.
742    fn new_checked<E>(
743        content: &'a str,
744        check: &mut impl FnMut() -> Result<(), E>,
745    ) -> Result<Self, E> {
746        let lines = content.lines().collect::<Vec<_>>();
747        let mut sections = std::collections::HashMap::new();
748        let mut keys = std::collections::HashMap::new();
749        let mut current_section = String::new();
750        for (index, raw_line) in lines.iter().enumerate() {
751            check_parser_iteration(index, check)?;
752            let line_number = index + 1;
753            let line = raw_line.trim();
754            if line.starts_with('[') && line.ends_with(']') {
755                current_section = normalize_toml_section(line.trim_matches(&['[', ']'][..]).trim());
756                sections.insert(current_section.clone(), line_number);
757                continue;
758            }
759            let Some((key, _value)) = line.split_once('=') else {
760                continue;
761            };
762            let key = key.trim().trim_matches('"').to_string();
763            if !key.is_empty() {
764                keys.insert((current_section.clone(), key), line_number);
765            }
766        }
767        Ok(Self {
768            lines,
769            sections,
770            keys,
771        })
772    }
773
774    /// Return the source line for a section declaration.
775    fn section_line(&self, section: &str) -> Option<usize> {
776        self.sections.get(section).copied()
777    }
778
779    /// Return the source line for a key in a section.
780    fn key_line(&self, section: &str, key: &str) -> Option<usize> {
781        self.keys
782            .get(&(section.to_string(), key.to_string()))
783            .copied()
784    }
785
786    /// Return source text for a one-based line number.
787    fn line_text(&self, line: usize) -> Option<&'a str> {
788        self.lines.get(line.checked_sub(1)?).copied()
789    }
790}
791
792/// Normalize quoted TOML section components into a dotted lookup key.
793fn normalize_toml_section(section: &str) -> String {
794    let mut parts = Vec::new();
795    let mut current = String::new();
796    let mut quote: Option<char> = None;
797    for character in section.chars() {
798        match (character, quote) {
799            ('"' | '\'', None) => quote = Some(character),
800            (value, Some(active)) if value == active => quote = None,
801            ('.', None) => {
802                if !current.is_empty() {
803                    parts.push(current.clone());
804                    current.clear();
805                }
806            }
807            (value, _) => current.push(value),
808        }
809    }
810    if !current.is_empty() {
811        parts.push(current);
812    }
813    parts.join(".")
814}
815
816/// Tree-sitter extraction result with parse health metadata.
817struct TreeSitterParse {
818    /// Extracted symbol graph.
819    graph: SymbolGraph,
820    /// Whether tree-sitter found syntax errors while parsing.
821    had_errors: bool,
822}
823
824/// Extract a graph through tree-sitter when the language has a grammar.
825fn extract_tree_sitter_graph<E>(
826    path: &str,
827    language: Option<&str>,
828    content: &str,
829    check: &mut impl FnMut() -> Result<(), E>,
830) -> Result<Option<TreeSitterParse>, E> {
831    let Some(language_name) = language else {
832        return Ok(None);
833    };
834    let Some(parser_language) = tree_sitter_language(language_name) else {
835        return Ok(None);
836    };
837    check()?;
838    let mut parser = Parser::new();
839    if parser.set_language(&parser_language).is_err() {
840        return Ok(None);
841    }
842    let mut parse_failure = None;
843    let mut progress = |_: &tree_sitter::ParseState| match check() {
844        Ok(()) => ControlFlow::Continue(()),
845        Err(error) => {
846            parse_failure = Some(error);
847            ControlFlow::Break(())
848        }
849    };
850    let bytes = content.as_bytes();
851    let mut read = |offset, _| bytes.get(offset..).unwrap_or_default();
852    let tree = parser.parse_with_options(
853        &mut read,
854        None,
855        Some(ParseOptions::new().progress_callback(&mut progress)),
856    );
857    if let Some(error) = parse_failure {
858        return Err(error);
859    }
860    check()?;
861    let Some(tree) = tree else {
862        return Ok(None);
863    };
864    let mut graph = empty_graph(path, language, ParserKind::TreeSitter);
865    let root = tree.root_node();
866    let had_errors = root.has_error();
867    visit_node(root, content, &mut graph, check)?;
868    check()?;
869    languages::augment_language_graph(&mut graph, content, check)?;
870    check()?;
871    Ok(Some(TreeSitterParse { graph, had_errors }))
872}
873
874/// Return a tree-sitter language for supported source families.
875fn tree_sitter_language(language: &str) -> Option<Language> {
876    Some(match tree_sitter_grammar(language)? {
877        TreeSitterGrammar::Rust => tree_sitter_rust::LANGUAGE.into(),
878        TreeSitterGrammar::Python => tree_sitter_python::LANGUAGE.into(),
879        TreeSitterGrammar::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
880        TreeSitterGrammar::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
881        TreeSitterGrammar::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
882        TreeSitterGrammar::Java => tree_sitter_java::LANGUAGE.into(),
883        TreeSitterGrammar::Kotlin => tree_sitter_kotlin_ng::LANGUAGE.into(),
884        TreeSitterGrammar::CSharp => tree_sitter_c_sharp::LANGUAGE.into(),
885        TreeSitterGrammar::Go => tree_sitter_go::LANGUAGE.into(),
886        TreeSitterGrammar::ObjectiveC => tree_sitter_objc::LANGUAGE.into(),
887        TreeSitterGrammar::Zig => tree_sitter_zig::LANGUAGE.into(),
888        TreeSitterGrammar::C => tree_sitter_c::LANGUAGE.into(),
889        TreeSitterGrammar::Cpp => tree_sitter_cpp::LANGUAGE.into(),
890    })
891}
892
893/// Recursively inspect one tree-sitter node.
894fn visit_node<E>(
895    node: Node<'_>,
896    content: &str,
897    graph: &mut SymbolGraph,
898    check: &mut impl FnMut() -> Result<(), E>,
899) -> Result<(), E> {
900    check()?;
901    if graph.symbols.len() < MAX_SYMBOLS_PER_FILE
902        && let Some(kind) = declaration_kind(node.kind())
903        && should_emit_declaration_symbol(node, content)
904    {
905        push_tree_symbol(graph, node, content, effective_declaration_kind(node, kind));
906    }
907    if graph.relations.len() < MAX_RELATIONS_PER_FILE {
908        if is_import_node(node.kind()) {
909            push_import_relation(graph, node, content);
910        } else if is_call_node(node.kind()) {
911            push_call_relation(graph, node, content);
912        }
913    }
914    let mut cursor = node.walk();
915    for child in node.named_children(&mut cursor) {
916        visit_node(child, content, graph, check)?;
917    }
918    Ok(())
919}
920
921/// Refine a declaration kind using surrounding syntax context.
922fn effective_declaration_kind(node: Node<'_>, kind: SymbolKind) -> SymbolKind {
923    if kind == SymbolKind::Function && declaration_is_method_context(node) {
924        return SymbolKind::Method;
925    }
926    if kind == SymbolKind::Value
927        && !is_local_value_declaration(node)
928        && declaration_has_direct_callable_initializer(node)
929    {
930        return SymbolKind::Function;
931    }
932    if kind == SymbolKind::Type {
933        if has_descendant_kind(node, &["struct_type"]) {
934            return SymbolKind::Struct;
935        }
936        if has_descendant_kind(node, &["interface_type"]) {
937            return SymbolKind::Interface;
938        }
939    }
940    kind
941}
942
943/// Return whether a function-like declaration belongs to an enclosing type.
944fn declaration_is_method_context(node: Node<'_>) -> bool {
945    matches!(
946        node.kind(),
947        "function_item" | "function_definition" | "function_declaration" | "function_declarator"
948    ) && (has_ancestor_kind(node.parent(), "impl_item")
949        || has_ancestor_kind(node.parent(), "class_definition")
950        || has_ancestor_kind(node.parent(), "class_declaration")
951        || has_ancestor_kind(node.parent(), "class_body")
952        || has_ancestor_kind(node.parent(), "class_specifier")
953        || has_ancestor_kind(node.parent(), "struct_specifier")
954        || has_ancestor_kind(node.parent(), "interface_declaration"))
955}
956
957/// Return whether this declaration node should become its own symbol row.
958fn should_emit_declaration_symbol(node: Node<'_>, content: &str) -> bool {
959    if is_object_literal_method(node) {
960        return object_literal_method_owner(node, content).is_some_and(|owner| owner.exported);
961    }
962    if node.kind() == "field_declaration"
963        && has_descendant_kind(node, &["function_declarator", "method_declarator"])
964    {
965        return false;
966    }
967    if matches!(node.kind(), "function_declarator" | "method_declarator") {
968        if is_type_member_declarator(node) {
969            return true;
970        }
971        return !has_declaration_ancestor(node.parent());
972    }
973    true
974}
975
976/// Return whether a C/C++ declarator is a type member prototype.
977fn is_type_member_declarator(node: Node<'_>) -> bool {
978    has_ancestor_kind(node.parent(), "field_declaration")
979        && (has_ancestor_kind(node.parent(), "class_specifier")
980            || has_ancestor_kind(node.parent(), "struct_specifier"))
981        && !has_ancestor_kind(node.parent(), "function_definition")
982}
983
984/// Return whether a parent chain already has a declaration symbol owner.
985fn has_declaration_ancestor(mut node: Option<Node<'_>>) -> bool {
986    while let Some(current) = node {
987        if declaration_kind(current.kind()).is_some() {
988            return true;
989        }
990        node = current.parent();
991    }
992    false
993}
994
995/// Return whether a value declaration initializes directly to a callable value.
996fn declaration_has_direct_callable_initializer(node: Node<'_>) -> bool {
997    if !matches!(
998        node.kind(),
999        "lexical_declaration" | "variable_declaration" | "variable_statement" | "var_declaration"
1000    ) {
1001        return false;
1002    }
1003    first_declaration_initializer(node).is_some_and(|initializer| {
1004        matches!(
1005            initializer.kind(),
1006            "arrow_function"
1007                | "function"
1008                | "function_expression"
1009                | "generator_function"
1010                | "lambda_expression"
1011        )
1012    })
1013}
1014
1015/// Return the first direct declaration initializer.
1016fn first_declaration_initializer(node: Node<'_>) -> Option<Node<'_>> {
1017    if let Some(value) = node.child_by_field_name("value") {
1018        return Some(value);
1019    }
1020    let mut cursor = node.walk();
1021    for child in node.named_children(&mut cursor) {
1022        if let Some(value) = child.child_by_field_name("value") {
1023            return Some(value);
1024        }
1025    }
1026    None
1027}
1028
1029/// Return whether a declaration is a local binding inside a callable body.
1030fn is_local_value_declaration(node: Node<'_>) -> bool {
1031    matches!(
1032        node.kind(),
1033        "lexical_declaration" | "variable_declaration" | "variable_statement" | "var_declaration"
1034    ) && has_ancestor_kind_any(
1035        node.parent(),
1036        &[
1037            "arrow_function",
1038            "function",
1039            "function_expression",
1040            "function_declaration",
1041            "generator_function",
1042            "method_definition",
1043            "method_declaration",
1044            "function_item",
1045            "function_definition",
1046            "function_declaration_with_receiver",
1047            "func_literal",
1048        ],
1049    )
1050}
1051
1052/// Return whether a method declaration belongs to an object literal, not a type.
1053fn is_object_literal_method(node: Node<'_>) -> bool {
1054    node.kind() == "method_definition"
1055        && has_ancestor_kind_any(node.parent(), &["object", "object_pattern", "pair"])
1056}
1057
1058/// Parent object metadata for a JavaScript object-literal method.
1059#[derive(Clone, Debug, Eq, PartialEq)]
1060struct ObjectLiteralMethodOwner {
1061    /// Object or export-assignment name that owns the method.
1062    name: String,
1063    /// Whether the owning object is part of the module API.
1064    exported: bool,
1065}
1066
1067/// Return the owner of an object-literal method when it is useful to index.
1068fn object_literal_method_owner(
1069    method_node: Node<'_>,
1070    content: &str,
1071) -> Option<ObjectLiteralMethodOwner> {
1072    if !is_object_literal_method(method_node) {
1073        return None;
1074    }
1075    let object = nearest_ancestor_kind(method_node.parent(), "object")?;
1076    object_literal_owner(object, content)
1077}
1078
1079/// Return the declaration or assignment that owns an object literal.
1080fn object_literal_owner(object: Node<'_>, content: &str) -> Option<ObjectLiteralMethodOwner> {
1081    let parent = object.parent()?;
1082    match parent.kind() {
1083        "variable_declarator" | "variable_declaration" => {
1084            let name = declarator_name(parent, content)?;
1085            Some(ObjectLiteralMethodOwner {
1086                name,
1087                exported: is_directly_exported_declaration(parent),
1088            })
1089        }
1090        "assignment_expression" | "augmented_assignment_expression" => {
1091            let target = parent
1092                .child_by_field_name("left")
1093                .or_else(|| first_named_child(parent))?;
1094            let name = compact_text(node_text(target, content).as_deref().unwrap_or(""));
1095            if name.is_empty() {
1096                return None;
1097            }
1098            let exported = name == "module.exports"
1099                || name.starts_with("module.exports.")
1100                || name == "exports"
1101                || name.starts_with("exports.");
1102            Some(ObjectLiteralMethodOwner { name, exported })
1103        }
1104        "export_statement" => Some(ObjectLiteralMethodOwner {
1105            name: "default".to_string(),
1106            exported: true,
1107        }),
1108        "pair" => {
1109            let property = parent
1110                .child_by_field_name("key")
1111                .and_then(|key| named_text(key, content))
1112                .unwrap_or_else(|| "object".to_string());
1113            let outer = nearest_ancestor_kind(parent.parent(), "object")
1114                .and_then(|outer| object_literal_owner(outer, content));
1115            outer.map(|owner| ObjectLiteralMethodOwner {
1116                name: format!("{}.{}", owner.name, property),
1117                exported: owner.exported,
1118            })
1119        }
1120        _ => None,
1121    }
1122}
1123
1124/// Return whether a declaration statement is directly wrapped in an export.
1125fn is_directly_exported_declaration(node: Node<'_>) -> bool {
1126    let mut current = Some(node);
1127    while let Some(candidate) = current {
1128        if has_direct_export_parent(candidate) {
1129            return true;
1130        }
1131        if matches!(
1132            candidate.kind(),
1133            "lexical_declaration" | "variable_declaration" | "variable_statement"
1134        ) {
1135            return false;
1136        }
1137        current = candidate.parent();
1138    }
1139    false
1140}
1141
1142/// Return whether a node has an ancestor of the given tree-sitter kind.
1143fn has_ancestor_kind(mut node: Option<Node<'_>>, kind: &str) -> bool {
1144    while let Some(current) = node {
1145        if current.kind() == kind {
1146            return true;
1147        }
1148        node = current.parent();
1149    }
1150    false
1151}
1152
1153/// Return whether a node has any ancestor with one of the given tree-sitter kinds.
1154fn has_ancestor_kind_any(mut node: Option<Node<'_>>, kinds: &[&str]) -> bool {
1155    while let Some(current) = node {
1156        if kinds.contains(&current.kind()) {
1157            return true;
1158        }
1159        node = current.parent();
1160    }
1161    false
1162}
1163
1164/// Return the nearest ancestor with the requested tree-sitter kind.
1165fn nearest_ancestor_kind<'tree>(mut node: Option<Node<'tree>>, kind: &str) -> Option<Node<'tree>> {
1166    while let Some(current) = node {
1167        if current.kind() == kind {
1168            return Some(current);
1169        }
1170        node = current.parent();
1171    }
1172    None
1173}
1174
1175/// Push a declaration symbol from a tree-sitter node.
1176fn push_tree_symbol(
1177    graph: &mut SymbolGraph,
1178    node: Node<'_>,
1179    content: &str,
1180    symbol_kind: SymbolKind,
1181) {
1182    let name = node_name(node, content)
1183        .unwrap_or_else(|| compact_text(node_text(node, content).as_deref().unwrap_or("")));
1184    if name.is_empty() {
1185        return;
1186    }
1187    let signature = declaration_signature(node, content);
1188    let parent = symbol_parent(node, content);
1189    let exported = has_direct_export_parent(node)
1190        || object_literal_method_owner(node, content).is_some_and(|owner| owner.exported)
1191        || is_exported_symbol(graph.language.as_deref(), &name, &signature);
1192    let documentation = symbol_documentation(node, content);
1193    push_symbol_with_metadata(
1194        graph,
1195        &name,
1196        symbol_kind,
1197        node.start_position().row + 1,
1198        node.end_position().row + 1,
1199        parent.clone(),
1200        Some(node.kind()),
1201        &signature,
1202        exported,
1203        documentation.as_deref(),
1204    );
1205    if let Some(parent_name) = parent {
1206        push_relation(
1207            graph,
1208            &parent_name,
1209            &name,
1210            RelationKind::Contains,
1211            node.start_position().row + 1,
1212            node.kind(),
1213        );
1214    }
1215}
1216
1217/// Return whether a declaration is directly wrapped by a JavaScript-like export.
1218fn has_direct_export_parent(node: Node<'_>) -> bool {
1219    node.parent()
1220        .is_some_and(|parent| parent.kind() == "export_statement")
1221}
1222
1223/// Return source content with a leading `ProjectAtlas` `Purpose:` header blanked.
1224fn content_without_leading_purpose_header(content: &str) -> Cow<'_, str> {
1225    let Some(start) = content.find(|character: char| !character.is_whitespace()) else {
1226        return Cow::Borrowed(content);
1227    };
1228    let rest = &content[start..];
1229    if let Some(end) = leading_purpose_block_end(rest) {
1230        return Cow::Owned(blank_prefix_preserving_newlines(content, start + end));
1231    }
1232    if let Some(end) = leading_purpose_line_end(rest) {
1233        return Cow::Owned(blank_prefix_preserving_newlines(content, start + end));
1234    }
1235    Cow::Borrowed(content)
1236}
1237
1238/// Return the byte end of a leading block comment when it is a purpose header.
1239fn leading_purpose_block_end(rest: &str) -> Option<usize> {
1240    if !(rest.starts_with("/**") || rest.starts_with("/*")) {
1241        return None;
1242    }
1243    let end = rest.find("*/")? + "*/".len();
1244    let documentation = rest[..end]
1245        .lines()
1246        .filter_map(|line| clean_doc_comment_line(line.trim()))
1247        .collect::<Vec<_>>()
1248        .join(" ");
1249    compact_documentation(&documentation)
1250        .is_some_and(|value| value.starts_with("Purpose:"))
1251        .then_some(end)
1252}
1253
1254/// Return the byte end of a leading line comment when it is a purpose header.
1255fn leading_purpose_line_end(rest: &str) -> Option<usize> {
1256    let line_end = rest.find('\n').map_or(rest.len(), |index| index + 1);
1257    let line = rest[..line_end].trim();
1258    let cleaned = line
1259        .strip_prefix("//")
1260        .or_else(|| line.strip_prefix('#'))
1261        .or_else(|| {
1262            line.strip_prefix("<!--")
1263                .and_then(|value| value.strip_suffix("-->"))
1264        })?
1265        .trim();
1266    cleaned.starts_with("Purpose:").then_some(line_end)
1267}
1268
1269/// Blank a source prefix without changing byte offsets or line numbers.
1270fn blank_prefix_preserving_newlines(content: &str, end: usize) -> String {
1271    let mut output = String::with_capacity(content.len());
1272    debug_assert!(content.is_char_boundary(end));
1273    for byte in &content.as_bytes()[..end] {
1274        output.push(if matches!(*byte, b'\n' | b'\r') {
1275            char::from(*byte)
1276        } else {
1277            ' '
1278        });
1279    }
1280    output.push_str(&content[end..]);
1281    output
1282}
1283
1284/// Return the semantic parent for a declaration symbol.
1285fn symbol_parent(node: Node<'_>, content: &str) -> Option<String> {
1286    if let Some(owner) = object_literal_method_owner(node, content) {
1287        return Some(owner.name);
1288    }
1289    if node.kind() == "function_item"
1290        && let Some(impl_node) = nearest_ancestor_kind(node.parent(), "impl_item")
1291    {
1292        return impl_type_name(impl_node, content);
1293    }
1294    if matches!(node.kind(), "function_declarator" | "method_declarator")
1295        && let Some(type_node) = nearest_ancestor_kind(node.parent(), "class_specifier")
1296            .or_else(|| nearest_ancestor_kind(node.parent(), "struct_specifier"))
1297    {
1298        return node_name(type_node, content);
1299    }
1300    enclosing_symbol_name(node.parent(), content)
1301}
1302
1303/// Map tree-sitter node kinds to `ProjectAtlas` symbol kinds.
1304fn declaration_kind(kind: &str) -> Option<SymbolKind> {
1305    match kind {
1306        "function_item"
1307        | "function_declaration"
1308        | "function_definition"
1309        | "function_declarator"
1310        | "func_literal" => Some(SymbolKind::Function),
1311        "method_definition"
1312        | "method_declarator"
1313        | "method_declaration"
1314        | "function_declaration_with_receiver"
1315        | "constructor_declaration"
1316        | "init_declaration" => Some(SymbolKind::Method),
1317        "class_declaration"
1318        | "class_definition"
1319        | "class_specifier"
1320        | "class_interface"
1321        | "class_implementation" => Some(SymbolKind::Class),
1322        "struct_item" | "struct_specifier" | "struct_declaration" => Some(SymbolKind::Struct),
1323        "enum_item" | "enum_declaration" | "enum_specifier" => Some(SymbolKind::Enum),
1324        "trait_item" => Some(SymbolKind::Trait),
1325        "interface_declaration" | "interface_type" => Some(SymbolKind::Interface),
1326        "mod_item"
1327        | "module_declaration"
1328        | "namespace_declaration"
1329        | "file_scoped_namespace_declaration"
1330        | "package_declaration"
1331        | "package_clause"
1332        | "package_header" => Some(SymbolKind::Module),
1333        "type_item" | "type_alias_declaration" | "type_declaration" => Some(SymbolKind::Type),
1334        "const_item"
1335        | "static_item"
1336        | "const_declaration"
1337        | "field_declaration"
1338        | "lexical_declaration"
1339        | "var_declaration"
1340        | "short_var_declaration" => Some(SymbolKind::Value),
1341        "use_declaration"
1342        | "import_statement"
1343        | "import_declaration"
1344        | "import_from_statement"
1345        | "using_directive"
1346        | "preproc_include" => Some(SymbolKind::Import),
1347        _ => None,
1348    }
1349}
1350
1351/// Return whether a node is an import-like relation.
1352fn is_import_node(kind: &str) -> bool {
1353    matches!(
1354        kind,
1355        "use_declaration"
1356            | "import_statement"
1357            | "import_declaration"
1358            | "import_from_statement"
1359            | "using_directive"
1360            | "preproc_include"
1361    )
1362}
1363
1364/// Return whether a node is a call-like relation.
1365fn is_call_node(kind: &str) -> bool {
1366    matches!(
1367        kind,
1368        "call_expression"
1369            | "method_invocation"
1370            | "invocation_expression"
1371            | "call"
1372            | "macro_invocation"
1373    )
1374}
1375
1376/// Return whether a subtree contains any node with one of the given kinds.
1377fn has_descendant_kind(node: Node<'_>, kinds: &[&str]) -> bool {
1378    let mut cursor = node.walk();
1379    for child in node.named_children(&mut cursor) {
1380        if kinds.contains(&child.kind()) || has_descendant_kind(child, kinds) {
1381            return true;
1382        }
1383    }
1384    false
1385}
1386
1387/// Push an import relation from an import node.
1388fn push_import_relation(graph: &mut SymbolGraph, node: Node<'_>, content: &str) {
1389    let import_text = compact_text(node_text(node, content).as_deref().unwrap_or(""));
1390    if import_text.is_empty() {
1391        return;
1392    }
1393    push_relation(
1394        graph,
1395        "<module>",
1396        &import_text,
1397        RelationKind::Imports,
1398        node.start_position().row + 1,
1399        &import_text,
1400    );
1401}
1402
1403/// Push a call relation from a call node.
1404fn push_call_relation(graph: &mut SymbolGraph, node: Node<'_>, content: &str) {
1405    let target_node = node
1406        .child_by_field_name("function")
1407        .or_else(|| first_named_child(node));
1408    let Some(target_node) = target_node else {
1409        return;
1410    };
1411    let target = compact_text(node_text(target_node, content).as_deref().unwrap_or(""));
1412    if target.is_empty() || target.len() > MAX_SNIPPET_CHARS {
1413        return;
1414    }
1415    let source = enclosing_symbol_name(node.parent(), content).unwrap_or_else(|| "<module>".into());
1416    let context = compact_text(node_text(node, content).as_deref().unwrap_or(""));
1417    push_relation(
1418        graph,
1419        &source,
1420        &target,
1421        RelationKind::Calls,
1422        node.start_position().row + 1,
1423        &context,
1424    );
1425    if graph.language.as_deref() == Some("rust")
1426        && rust_target_invokes_function_item(target_node, content)
1427        && let Some(arguments) = node.child_by_field_name("arguments")
1428        && let Some(callback) = first_named_child(arguments)
1429        && callback.kind() == "scoped_identifier"
1430    {
1431        let callback = compact_text(node_text(callback, content).as_deref().unwrap_or(""));
1432        if !callback.is_empty() && callback.len() <= MAX_SNIPPET_CHARS {
1433            push_relation(
1434                graph,
1435                &source,
1436                &callback,
1437                RelationKind::Calls,
1438                node.start_position().row + 1,
1439                &context,
1440            );
1441        }
1442    }
1443}
1444
1445/// Return whether one Rust method target proves that its function-item argument is invoked.
1446fn rust_target_invokes_function_item(target: Node<'_>, content: &str) -> bool {
1447    if target.kind() != "field_expression"
1448        || target
1449            .child_by_field_name("field")
1450            .and_then(|field| node_text(field, content))
1451            .as_deref()
1452            != Some("then")
1453    {
1454        return false;
1455    }
1456    target
1457        .child_by_field_name("value")
1458        .is_some_and(|receiver| rust_expression_is_definitely_bool(receiver, content))
1459}
1460
1461/// Recognize Rust expressions whose syntax itself guarantees a Boolean value.
1462fn rust_expression_is_definitely_bool(mut expression: Node<'_>, content: &str) -> bool {
1463    while expression.kind() == "parenthesized_expression" {
1464        let Some(inner) = first_named_child(expression) else {
1465            return false;
1466        };
1467        expression = inner;
1468    }
1469    match expression.kind() {
1470        "boolean_literal" => true,
1471        "binary_expression" => {
1472            let (Some(left), Some(right)) = (
1473                expression.child_by_field_name("left"),
1474                expression.child_by_field_name("right"),
1475            ) else {
1476                return false;
1477            };
1478            content
1479                .get(left.end_byte()..right.start_byte())
1480                .is_some_and(|operator| {
1481                    matches!(
1482                        operator.trim(),
1483                        "==" | "!=" | "<" | "<=" | ">" | ">=" | "&&" | "||"
1484                    )
1485                })
1486        }
1487        _ => false,
1488    }
1489}
1490
1491/// Return the first named child of a node.
1492fn first_named_child(node: Node<'_>) -> Option<Node<'_>> {
1493    let mut cursor = node.walk();
1494    node.named_children(&mut cursor).next()
1495}
1496
1497/// Extract a human-readable symbol name from common tree-sitter fields.
1498fn node_name(node: Node<'_>, content: &str) -> Option<String> {
1499    if let Some(name) = declaration_specific_name(node, content) {
1500        return Some(name);
1501    }
1502    if let Some(declarator) = node.child_by_field_name("declarator")
1503        && let Some(name) = declarator_name(declarator, content)
1504    {
1505        return Some(name);
1506    }
1507    for field_name in ["name", "field", "property", "type", "path"] {
1508        if let Some(child) = node.child_by_field_name(field_name)
1509            && let Some(name) = named_text(child, content)
1510        {
1511            return Some(name);
1512        }
1513    }
1514    let mut cursor = node.walk();
1515    for child in node.named_children(&mut cursor) {
1516        if matches!(
1517            child.kind(),
1518            "identifier" | "type_identifier" | "property_identifier" | "field_identifier"
1519        ) && let Some(name) = named_text(child, content)
1520        {
1521            return Some(name);
1522        }
1523    }
1524    None
1525}
1526
1527/// Extract names that need language-specific cleanup from a declaration node.
1528fn declaration_specific_name(node: Node<'_>, content: &str) -> Option<String> {
1529    match node.kind() {
1530        "package_declaration" | "package_clause" | "package_header" => {
1531            prefixed_declaration_name(node, content, &["package"])
1532        }
1533        "namespace_declaration" | "file_scoped_namespace_declaration" => {
1534            prefixed_declaration_name(node, content, &["namespace"])
1535        }
1536        "module_declaration" => {
1537            prefixed_declaration_name(node, content, &["module", "declare module"])
1538        }
1539        "type_declaration" => keyword_identifier_name(node, content, "type"),
1540        "lexical_declaration"
1541        | "variable_declaration"
1542        | "variable_statement"
1543        | "var_declaration" => first_variable_declarator_name(node, content),
1544        _ => None,
1545    }
1546}
1547
1548/// Extract a declaration name by removing a language keyword prefix.
1549fn prefixed_declaration_name(node: Node<'_>, content: &str, prefixes: &[&str]) -> Option<String> {
1550    let text = compact_text(&node_text(node, content)?);
1551    for prefix in prefixes {
1552        let Some(rest) = text.strip_prefix(prefix) else {
1553            continue;
1554        };
1555        let name = rest
1556            .trim()
1557            .trim_matches('"')
1558            .trim_end_matches(';')
1559            .trim_end_matches('{')
1560            .trim()
1561            .to_string();
1562        if !name.is_empty() {
1563            return Some(name);
1564        }
1565    }
1566    None
1567}
1568
1569/// Extract the first identifier after a declaration keyword.
1570fn keyword_identifier_name(node: Node<'_>, content: &str, keyword: &str) -> Option<String> {
1571    let text = compact_text(&node_text(node, content)?);
1572    let rest = text.strip_prefix(keyword)?.trim();
1573    rest.split_whitespace()
1574        .next()
1575        .map(|name| name.trim_matches(';').to_string())
1576        .filter(|name| !name.is_empty())
1577}
1578
1579/// Extract the implemented Rust type name from an `impl` block.
1580fn impl_type_name(node: Node<'_>, content: &str) -> Option<String> {
1581    if let Some(type_node) = node.child_by_field_name("type")
1582        && let Some(name) = named_text(type_node, content)
1583    {
1584        return Some(clean_type_name(&name));
1585    }
1586    let mut cursor = node.walk();
1587    for child in node.named_children(&mut cursor) {
1588        if matches!(
1589            child.kind(),
1590            "type_identifier" | "scoped_type_identifier" | "generic_type" | "identifier"
1591        ) && let Some(name) = named_text(child, content)
1592        {
1593            return Some(clean_type_name(&name));
1594        }
1595    }
1596    None
1597}
1598
1599/// Remove Rust type adornments from a parent type name.
1600fn clean_type_name(value: &str) -> String {
1601    value
1602        .trim()
1603        .trim_start_matches('&')
1604        .trim_start_matches("mut ")
1605        .split(['<', ' ', '{'])
1606        .next()
1607        .unwrap_or(value)
1608        .trim()
1609        .to_string()
1610}
1611
1612/// Return the first declared variable name in a declaration statement.
1613fn first_variable_declarator_name(node: Node<'_>, content: &str) -> Option<String> {
1614    let mut cursor = node.walk();
1615    for child in node.named_children(&mut cursor) {
1616        if matches!(
1617            child.kind(),
1618            "variable_declarator" | "variable_declaration" | "identifier"
1619        ) && let Some(name) = declarator_name(child, content)
1620        {
1621            return Some(name);
1622        }
1623    }
1624    None
1625}
1626
1627/// Extract the declared name from a declarator subtree.
1628fn declarator_name(node: Node<'_>, content: &str) -> Option<String> {
1629    if let Some(name_node) = node.child_by_field_name("name")
1630        && let Some(name) = named_text(name_node, content)
1631    {
1632        return Some(strip_declarator_noise(&name));
1633    }
1634    if matches!(
1635        node.kind(),
1636        "identifier" | "type_identifier" | "property_identifier" | "field_identifier"
1637    ) && let Some(name) = named_text(node, content)
1638    {
1639        return Some(strip_declarator_noise(&name));
1640    }
1641    let mut cursor = node.walk();
1642    for child in node.named_children(&mut cursor) {
1643        if let Some(name) = declarator_name(child, content) {
1644            return Some(name);
1645        }
1646    }
1647    None
1648}
1649
1650/// Remove initializer or parameter text accidentally captured with a declarator.
1651fn strip_declarator_noise(value: &str) -> String {
1652    value
1653        .split(['=', '(', ':'])
1654        .next()
1655        .unwrap_or(value)
1656        .trim()
1657        .to_string()
1658}
1659
1660/// Return compact text for a likely name node.
1661fn named_text(node: Node<'_>, content: &str) -> Option<String> {
1662    let text = node_text(node, content)?;
1663    let compact = compact_text(&text);
1664    if compact.is_empty() {
1665        None
1666    } else {
1667        Some(compact)
1668    }
1669}
1670
1671/// Build a compact declaration signature for a node.
1672fn declaration_signature(node: Node<'_>, content: &str) -> String {
1673    let header_end = declaration_body_start(node).unwrap_or_else(|| node.end_byte());
1674    let mut signature = String::new();
1675    append_declaration_tokens(node, content, header_end, &mut signature);
1676    if signature.is_empty() {
1677        node_text(node, content).map_or_else(String::new, |raw| compact_text(&raw))
1678    } else {
1679        signature
1680    }
1681}
1682
1683/// Return the byte at which executable or member body syntax begins.
1684fn declaration_body_start(node: Node<'_>) -> Option<usize> {
1685    if declaration_has_direct_callable_initializer(node)
1686        && let Some(initializer) = first_declaration_initializer(node)
1687        && let Some(body) = initializer.child_by_field_name("body")
1688    {
1689        return Some(body.start_byte());
1690    }
1691    if declaration_kind(node.kind()) == Some(SymbolKind::Value)
1692        && let Some(initializer) = first_declaration_initializer(node)
1693    {
1694        return Some(initializer.start_byte());
1695    }
1696    if let Some(body) = node.child_by_field_name("body") {
1697        return Some(body.start_byte());
1698    }
1699    let mut cursor = node.walk();
1700    node.named_children(&mut cursor)
1701        .find(|child| {
1702            matches!(
1703                child.kind(),
1704                "block" | "compound_statement" | "statement_block"
1705            ) || child.kind().ends_with("_body")
1706        })
1707        .map(|body| body.start_byte())
1708}
1709
1710/// Append non-comment leaf tokens before a declaration body in source order.
1711fn append_declaration_tokens(
1712    node: Node<'_>,
1713    content: &str,
1714    header_end: usize,
1715    signature: &mut String,
1716) {
1717    if node.start_byte() >= header_end || node.kind().contains("comment") {
1718        return;
1719    }
1720    if node.child_count() == 0 {
1721        if node.end_byte() <= header_end
1722            && let Ok(token) = node.utf8_text(content.as_bytes())
1723        {
1724            let token = token.trim();
1725            if !token.is_empty() {
1726                if !signature.is_empty() {
1727                    signature.push(' ');
1728                }
1729                signature.push_str(token);
1730            }
1731        }
1732        return;
1733    }
1734    let mut cursor = node.walk();
1735    for child in node.children(&mut cursor) {
1736        append_declaration_tokens(child, content, header_end, signature);
1737    }
1738}
1739
1740/// Return whether a declaration is exported or publicly visible.
1741fn is_exported_symbol(language: Option<&str>, name: &str, signature: &str) -> bool {
1742    let trimmed = signature.trim_start();
1743    trimmed.starts_with("pub ")
1744        || trimmed.starts_with("pub(")
1745        || trimmed.starts_with("export ")
1746        || trimmed.starts_with("public ")
1747        || trimmed.starts_with("open ")
1748        || matches!(language, Some("go")) && starts_with_uppercase(name)
1749}
1750
1751/// Return whether a symbol name starts with an uppercase Unicode scalar.
1752fn starts_with_uppercase(value: &str) -> bool {
1753    value.chars().next().is_some_and(char::is_uppercase)
1754}
1755
1756/// Extract documentation attached to a declaration.
1757fn symbol_documentation(node: Node<'_>, content: &str) -> Option<String> {
1758    preceding_documentation(content, node.start_position().row + 1)
1759        .or_else(|| leading_docstring_literal(node, content))
1760}
1761
1762/// Extract contiguous doc-comment text immediately preceding a declaration.
1763fn preceding_documentation(content: &str, line_start: usize) -> Option<String> {
1764    let lines = content.lines().collect::<Vec<_>>();
1765    if line_start <= 1 || lines.is_empty() {
1766        return None;
1767    }
1768    let mut index = line_start.saturating_sub(2);
1769    let mut collected = Vec::new();
1770    let mut saw_doc = false;
1771    loop {
1772        let trimmed = lines[index].trim();
1773        if trimmed.is_empty() {
1774            break;
1775        }
1776        if !saw_doc && is_attribute_line(trimmed) {
1777            if index == 0 {
1778                break;
1779            }
1780            index -= 1;
1781            continue;
1782        }
1783        if let Some(line) = clean_doc_comment_line(trimmed) {
1784            collected.push(line);
1785            saw_doc = true;
1786            if index == 0 {
1787                break;
1788            }
1789            index -= 1;
1790            continue;
1791        }
1792        break;
1793    }
1794    collected.reverse();
1795    compact_documentation(&collected.join(" "))
1796}
1797
1798/// Return whether a line is a Rust or language attribute between docs and code.
1799fn is_attribute_line(trimmed: &str) -> bool {
1800    trimmed.starts_with("#[") || trimmed.starts_with('@')
1801}
1802
1803/// Strip common doc-comment markers from one line.
1804fn clean_doc_comment_line(trimmed: &str) -> Option<String> {
1805    let cleaned = if let Some(rest) = trimmed.strip_prefix("///") {
1806        rest
1807    } else if let Some(rest) = trimmed.strip_prefix("/**") {
1808        rest
1809    } else if let Some(rest) = trimmed.strip_prefix("*/") {
1810        rest
1811    } else if let Some(rest) = trimmed.strip_prefix('*') {
1812        rest
1813    } else if let Some(rest) = trimmed.strip_prefix("# ") {
1814        rest
1815    } else {
1816        trimmed.strip_prefix("## ")?
1817    }
1818    .trim()
1819    .trim_end_matches("*/")
1820    .trim()
1821    .to_string();
1822    Some(cleaned)
1823}
1824
1825/// Extract a Python-style leading string literal from a declaration body.
1826fn leading_docstring_literal(node: Node<'_>, content: &str) -> Option<String> {
1827    let mut cursor = node.walk();
1828    for child in node.named_children(&mut cursor) {
1829        if matches!(
1830            child.kind(),
1831            "block" | "statement_block" | "class_body" | "declaration_list"
1832        ) && let Some(docstring) = first_block_string_literal(child, content)
1833        {
1834            return Some(docstring);
1835        }
1836    }
1837    None
1838}
1839
1840/// Return the first string literal in a declaration body when it is the body lead.
1841fn first_block_string_literal(block: Node<'_>, content: &str) -> Option<String> {
1842    let mut cursor = block.walk();
1843    let first = block.named_children(&mut cursor).next()?;
1844    if first.kind() == "expression_statement" {
1845        let mut nested_cursor = first.walk();
1846        if let Some(string_node) = first
1847            .named_children(&mut nested_cursor)
1848            .find(|child| child.kind().contains("string"))
1849        {
1850            return clean_string_literal_doc(&node_text(string_node, content)?);
1851        }
1852    }
1853    if first.kind().contains("string") {
1854        return clean_string_literal_doc(&node_text(first, content)?);
1855    }
1856    None
1857}
1858
1859/// Clean a source string literal into documentation text.
1860fn clean_string_literal_doc(value: &str) -> Option<String> {
1861    let trimmed = value.trim();
1862    let unquoted = trimmed
1863        .strip_prefix("\"\"\"")
1864        .and_then(|text| text.strip_suffix("\"\"\""))
1865        .or_else(|| {
1866            trimmed
1867                .strip_prefix("'''")
1868                .and_then(|text| text.strip_suffix("'''"))
1869        })
1870        .or_else(|| {
1871            trimmed
1872                .strip_prefix('"')
1873                .and_then(|text| text.strip_suffix('"'))
1874        })
1875        .or_else(|| {
1876            trimmed
1877                .strip_prefix('\'')
1878                .and_then(|text| text.strip_suffix('\''))
1879        })
1880        .unwrap_or(trimmed);
1881    compact_documentation(unquoted)
1882}
1883
1884/// Normalize extracted documentation into one bounded line.
1885fn compact_documentation(value: &str) -> Option<String> {
1886    let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
1887    if compact.is_empty() {
1888        None
1889    } else {
1890        Some(truncate_chars(&compact, MAX_DOC_CHARS))
1891    }
1892}
1893
1894/// Find the nearest containing declaration symbol name.
1895fn enclosing_symbol_name(mut node: Option<Node<'_>>, content: &str) -> Option<String> {
1896    while let Some(current) = node {
1897        if declaration_kind(current.kind()).is_some()
1898            && let Some(name) = node_name(current, content)
1899        {
1900            return Some(name);
1901        }
1902        node = current.parent();
1903    }
1904    None
1905}
1906
1907/// Return UTF-8 text for a tree-sitter node.
1908fn node_text(node: Node<'_>, content: &str) -> Option<String> {
1909    node.utf8_text(content.as_bytes())
1910        .ok()
1911        .map(ToString::to_string)
1912}
1913
1914/// Extract symbols through conservative declaration regexes.
1915#[cfg(test)]
1916fn extract_fallback_graph(path: &str, language: Option<&str>, content: &str) -> SymbolGraph {
1917    match extract_fallback_graph_checked(path, language, content, &mut || Ok::<(), Infallible>(()))
1918    {
1919        Ok(graph) => graph,
1920        Err(unreachable) => match unreachable {},
1921    }
1922}
1923
1924/// Extract fallback symbols while observing cooperative parser control.
1925fn extract_fallback_graph_checked<E>(
1926    path: &str,
1927    language: Option<&str>,
1928    content: &str,
1929    check: &mut impl FnMut() -> Result<(), E>,
1930) -> Result<SymbolGraph, E> {
1931    check()?;
1932    let mut graph = empty_graph(path, language, ParserKind::Fallback);
1933    let patterns = fallback_patterns();
1934    check()?;
1935    for (line_index, line) in content.lines().enumerate() {
1936        check_parser_iteration(line_index, check)?;
1937        let trimmed = line.trim();
1938        for pattern in &patterns {
1939            if let Some(capture) = pattern.regex.captures(trimmed)
1940                && let Some(name) = capture.get(1)
1941            {
1942                push_symbol(
1943                    &mut graph,
1944                    name.as_str(),
1945                    pattern.kind,
1946                    line_index + 1,
1947                    line_index + 1,
1948                    None,
1949                    Some(pattern.detail),
1950                    trimmed,
1951                );
1952                break;
1953            }
1954        }
1955        if is_fallback_import(trimmed) {
1956            push_relation(
1957                &mut graph,
1958                "<module>",
1959                trimmed,
1960                RelationKind::Imports,
1961                line_index + 1,
1962                trimmed,
1963            );
1964        }
1965    }
1966    check()?;
1967    languages::augment_fallback_language_graph(&mut graph, content, check)?;
1968    check()?;
1969    Ok(graph)
1970}
1971
1972/// Regex plus mapped symbol kind for fallback extraction.
1973struct FallbackPattern {
1974    /// Compiled fallback regex.
1975    regex: Regex,
1976    /// Symbol kind emitted when the regex matches.
1977    kind: SymbolKind,
1978    /// Stable detail string for the fallback source.
1979    detail: &'static str,
1980}
1981
1982/// Build fallback declaration regexes.
1983fn fallback_patterns() -> Vec<FallbackPattern> {
1984    let specs = [
1985        (
1986            r"^(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)",
1987            SymbolKind::Function,
1988            "fallback-python-function",
1989        ),
1990        (
1991            r"^class\s+([A-Za-z_][A-Za-z0-9_]*)",
1992            SymbolKind::Class,
1993            "fallback-class",
1994        ),
1995        (
1996            r"^function\s+([A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z_][A-Za-z0-9_]*)+)\b",
1997            SymbolKind::Function,
1998            "fallback-powershell-function",
1999        ),
2000        (
2001            r"^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
2002            SymbolKind::Function,
2003            "fallback-js-function",
2004        ),
2005        (
2006            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",
2007            SymbolKind::Value,
2008            "fallback-composition-binding",
2009        ),
2010        (
2011            r"^(?:pub\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)",
2012            SymbolKind::Function,
2013            "fallback-rust-function",
2014        ),
2015        (
2016            r"^(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)",
2017            SymbolKind::Type,
2018            "fallback-rust-type",
2019        ),
2020        (
2021            r"^(?:func|fun)\s+([A-Za-z_][A-Za-z0-9_]*)",
2022            SymbolKind::Function,
2023            "fallback-function",
2024        ),
2025        (
2026            r"^(?:public|private|protected|internal|static|\s)+\s*[A-Za-z0-9_<>,\[\]?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(",
2027            SymbolKind::Method,
2028            "fallback-c-family-method",
2029        ),
2030    ];
2031    let mut patterns = Vec::new();
2032    for (source, kind, detail) in specs {
2033        if let Ok(regex) = Regex::new(source) {
2034            patterns.push(FallbackPattern {
2035                regex,
2036                kind,
2037                detail,
2038            });
2039        }
2040    }
2041    patterns
2042}
2043
2044/// Return whether a line looks import-like in fallback mode.
2045fn is_fallback_import(line: &str) -> bool {
2046    matches!(
2047        line.split_whitespace().next(),
2048        Some("import" | "from" | "use" | "using" | "include" | "require")
2049    ) || line.starts_with("#include")
2050}
2051
2052/// Create an empty graph shell.
2053fn empty_graph(path: &str, language: Option<&str>, parser: ParserKind) -> SymbolGraph {
2054    SymbolGraph {
2055        path: path.to_string(),
2056        language: language.map(ToString::to_string),
2057        parser,
2058        symbols: Vec::new(),
2059        relations: Vec::new(),
2060    }
2061}
2062
2063/// Push a symbol while enforcing per-file graph bounds.
2064fn push_symbol(
2065    graph: &mut SymbolGraph,
2066    name: &str,
2067    kind: SymbolKind,
2068    line_start: usize,
2069    line_end: usize,
2070    parent: Option<String>,
2071    detail: Option<&str>,
2072    signature: &str,
2073) {
2074    push_symbol_with_metadata(
2075        graph, name, kind, line_start, line_end, parent, detail, signature, false, None,
2076    );
2077}
2078
2079/// Push a symbol with optional metadata while enforcing graph bounds.
2080fn push_symbol_with_metadata(
2081    graph: &mut SymbolGraph,
2082    name: &str,
2083    kind: SymbolKind,
2084    line_start: usize,
2085    line_end: usize,
2086    parent: Option<String>,
2087    detail: Option<&str>,
2088    signature: &str,
2089    exported: bool,
2090    documentation: Option<&str>,
2091) {
2092    if graph.symbols.len() >= MAX_SYMBOLS_PER_FILE {
2093        return;
2094    }
2095    let cleaned_name = compact_text(name);
2096    if cleaned_name.is_empty() {
2097        return;
2098    }
2099    graph.symbols.push(CodeSymbol {
2100        path: graph.path.clone(),
2101        language: graph.language.clone(),
2102        name: cleaned_name,
2103        kind,
2104        signature: truncate_chars_at_boundary(&compact_text(signature), MAX_SNIPPET_CHARS),
2105        exported,
2106        documentation: documentation.map(ToString::to_string),
2107        line_start,
2108        line_end: line_end.max(line_start),
2109        parent,
2110        parser: graph.parser,
2111        detail: detail.map(ToString::to_string),
2112    });
2113}
2114
2115/// Push a relation while enforcing per-file graph bounds.
2116fn push_relation(
2117    graph: &mut SymbolGraph,
2118    source_name: &str,
2119    target_name: &str,
2120    kind: RelationKind,
2121    line: usize,
2122    context: &str,
2123) {
2124    if graph.relations.len() >= MAX_RELATIONS_PER_FILE {
2125        return;
2126    }
2127    let target = compact_text(target_name);
2128    if target.is_empty() {
2129        return;
2130    }
2131    graph.relations.push(SymbolRelation {
2132        path: graph.path.clone(),
2133        source_name: truncate_chars_at_boundary(&compact_text(source_name), MAX_SNIPPET_CHARS),
2134        target_name: truncate_chars_at_boundary(&target, MAX_SNIPPET_CHARS),
2135        kind,
2136        line,
2137        context: truncate_chars_at_boundary(&compact_text(context), MAX_SNIPPET_CHARS),
2138        parser: graph.parser,
2139    });
2140}
2141
2142/// Compact whitespace in a parser text fragment.
2143fn compact_text(value: &str) -> String {
2144    value.split_whitespace().collect::<Vec<_>>().join(" ")
2145}
2146
2147/// Truncate a string to a maximum number of Unicode scalar values.
2148fn truncate_chars(value: &str, max_chars: usize) -> String {
2149    if value.chars().count() <= max_chars {
2150        return value.to_string();
2151    }
2152    value.chars().take(max_chars).collect()
2153}
2154
2155/// Truncate a long snippet at a stable syntactic boundary and mark omission.
2156fn truncate_chars_at_boundary(value: &str, max_chars: usize) -> String {
2157    let value_chars = value.chars().count();
2158    if value_chars <= max_chars {
2159        return value.to_string();
2160    }
2161    let marker = "...";
2162    let marker_chars = marker.chars().count();
2163    if max_chars <= marker_chars {
2164        return value.chars().take(max_chars).collect();
2165    }
2166    let target_chars = max_chars - marker_chars;
2167    let mut fallback_end = 0_usize;
2168    let mut boundary_end = None;
2169    for (char_index, (index, character)) in value.char_indices().enumerate() {
2170        if char_index >= target_chars {
2171            break;
2172        }
2173        fallback_end = index + character.len_utf8();
2174        if is_snippet_boundary(character) {
2175            boundary_end = Some(fallback_end);
2176        }
2177    }
2178    let end = boundary_end.unwrap_or(fallback_end);
2179    let prefix = value[..end]
2180        .trim_end_matches(|character: char| {
2181            character.is_whitespace() || matches!(character, ',' | ';' | ':' | '{')
2182        })
2183        .to_string();
2184    if prefix.is_empty() {
2185        format!(
2186            "{}{marker}",
2187            value.chars().take(target_chars).collect::<String>()
2188        )
2189    } else {
2190        format!("{prefix}{marker}")
2191    }
2192}
2193
2194/// Return whether a character is a good truncation boundary for source snippets.
2195fn is_snippet_boundary(character: char) -> bool {
2196    character.is_whitespace()
2197        || matches!(
2198            character,
2199            ',' | ';' | ':' | '{' | '}' | '(' | ')' | '[' | ']' | '/' | '\\' | '.'
2200        )
2201}
2202
2203#[cfg(test)]
2204mod tests {
2205    use super::{
2206        MAX_SYMBOLS_PER_FILE, content_without_leading_purpose_header, empty_graph,
2207        extract_cargo_manifest_graph_checked, extract_fallback_graph,
2208        extract_fallback_graph_checked, extract_powershell_graph_checked, extract_symbol_graph,
2209        extract_symbol_graph_checked, extract_symbol_graph_controlled,
2210        extract_vue_sfc_graph_checked, languages, specialized_languages,
2211    };
2212    use projectatlas_core::symbols::{
2213        CodeSymbol, ParserKind, RelationKind, SymbolGraph, SymbolKind,
2214    };
2215    use projectatlas_core::{
2216        IndexCancellation, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
2217    };
2218
2219    fn tree_symbol<'a>(
2220        graph: &'a SymbolGraph,
2221        kind: SymbolKind,
2222        name: &str,
2223        parent: Option<&str>,
2224        signature_fragment: &str,
2225    ) -> Option<&'a CodeSymbol> {
2226        graph.symbols.iter().find(|symbol| {
2227            symbol.parser == ParserKind::TreeSitter
2228                && symbol.kind == kind
2229                && symbol.name == name
2230                && symbol.parent.as_deref() == parent
2231                && symbol.signature.contains(signature_fragment)
2232        })
2233    }
2234
2235    #[test]
2236    fn controlled_extraction_consumes_cancellation_and_preserves_compatibility() {
2237        let fixtures = [
2238            (
2239                "src/lib.rs",
2240                Some("rust"),
2241                "pub struct Atlas;\nimpl Atlas { pub fn scan(&self) {} }\n",
2242            ),
2243            (
2244                "Cargo.toml",
2245                Some("cargo-manifest"),
2246                "[package]\nname = \"atlas\"\n[dependencies]\nserde = \"1\"\n",
2247            ),
2248            (
2249                "Cargo.lock",
2250                Some("cargo-lock"),
2251                "[[package]]\nname = \"atlas\"\nversion = \"0.1.0\"\n",
2252            ),
2253            (
2254                "src/App.vue",
2255                Some("vue"),
2256                "const props = defineProps<{ id: string }>()\n",
2257            ),
2258            (
2259                "scripts/Invoke-Atlas.ps1",
2260                Some("powershell"),
2261                "function Invoke-Atlas { return 1 }\n",
2262            ),
2263            (
2264                "config/atlas.txt",
2265                Some("text"),
2266                "function fallbackOnly() {}\n",
2267            ),
2268            (
2269                "src/Atlas.kt",
2270                Some("kotlin"),
2271                "package atlas\nclass Atlas {\nfun scan() {}\n}\n",
2272            ),
2273            ("build.gradle", Some("groovy"), "tasks.register('atlas')\n"),
2274        ];
2275        for &(path, language, source) in &fixtures {
2276            let expected = extract_symbol_graph(path, language, source);
2277            let active = IndexWorkControl::new(IndexCancellation::new(), None);
2278            assert_eq!(
2279                extract_symbol_graph_controlled(path, language, source, &active),
2280                Ok(expected),
2281                "controlled extraction changed compatibility output for {path}"
2282            );
2283        }
2284
2285        let cancellation = IndexCancellation::new();
2286        let control = IndexWorkControl::new(cancellation.clone(), None);
2287        let mut checkpoints = 0;
2288        let cancelled =
2289            extract_symbol_graph_checked("src/lib.rs", Some("rust"), fixtures[0].2, &mut || {
2290                checkpoints += 1;
2291                if checkpoints == 3 {
2292                    cancellation.cancel();
2293                }
2294                control.check(IndexWorkStage::SymbolParsing)
2295            });
2296        assert_eq!(
2297            cancelled,
2298            Err(IndexWorkFailure::Cancelled {
2299                stage: IndexWorkStage::SymbolParsing,
2300            })
2301        );
2302        assert_eq!(checkpoints, 3);
2303
2304        macro_rules! assert_parser_cancels_at {
2305            ($label:expr, $checkpoint:expr, $run:expr) => {{
2306                let cancellation = IndexCancellation::new();
2307                let control = IndexWorkControl::new(cancellation.clone(), None);
2308                let mut checkpoints = 0;
2309                let mut check = || {
2310                    checkpoints += 1;
2311                    if checkpoints == $checkpoint {
2312                        cancellation.cancel();
2313                    }
2314                    control.check(IndexWorkStage::SymbolParsing)
2315                };
2316                let cancelled = ($run)(&mut check);
2317                assert_eq!(
2318                    cancelled,
2319                    Err(IndexWorkFailure::Cancelled {
2320                        stage: IndexWorkStage::SymbolParsing,
2321                    }),
2322                    "{} did not observe cancellation inside its owned parser loop",
2323                    $label
2324                );
2325                assert_eq!(
2326                    checkpoints, $checkpoint,
2327                    "unexpected parser checkpoint path for {}",
2328                    $label
2329                );
2330            }};
2331        }
2332
2333        assert_parser_cancels_at!("Cargo manifest", 3, |check| {
2334            extract_cargo_manifest_graph_checked(fixtures[1].0, fixtures[1].1, fixtures[1].2, check)
2335        });
2336        assert_parser_cancels_at!("fallback", 3, |check| {
2337            extract_fallback_graph_checked(fixtures[5].0, fixtures[5].1, fixtures[5].2, check)
2338        });
2339        assert_parser_cancels_at!("Vue structural adapter", 8, |check| {
2340            extract_vue_sfc_graph_checked(fixtures[3].0, fixtures[3].1, fixtures[3].2, check)
2341        });
2342        assert_parser_cancels_at!("PowerShell structural adapter", 8, |check| {
2343            extract_powershell_graph_checked(fixtures[4].0, fixtures[4].1, fixtures[4].2, check)
2344        });
2345
2346        let mut native_augmentation =
2347            empty_graph(fixtures[6].0, fixtures[6].1, ParserKind::TreeSitter);
2348        assert_parser_cancels_at!("native language augmentation", 3, |check| {
2349            languages::augment_language_graph(&mut native_augmentation, fixtures[6].2, check)
2350                .map(|()| native_augmentation.clone())
2351        });
2352
2353        let mut fallback_augmentation =
2354            empty_graph(fixtures[7].0, fixtures[7].1, ParserKind::Fallback);
2355        assert_parser_cancels_at!("fallback language augmentation", 4, |check| {
2356            languages::augment_fallback_language_graph(
2357                &mut fallback_augmentation,
2358                fixtures[7].2,
2359                check,
2360            )
2361            .map(|()| fallback_augmentation.clone())
2362        });
2363    }
2364
2365    #[test]
2366    fn supplied_language_selects_specialized_symbol_owner() {
2367        let cargo = extract_symbol_graph(
2368            "config/manifest.txt",
2369            Some("cargo-manifest"),
2370            "[package]\nname = \"atlas\"\n",
2371        );
2372        assert_eq!(cargo.parser, ParserKind::Manifest);
2373        assert!(
2374            cargo
2375                .symbols
2376                .iter()
2377                .any(|symbol| { symbol.kind == SymbolKind::Package && symbol.name == "atlas" })
2378        );
2379
2380        let vue = extract_symbol_graph(
2381            "config/component.txt",
2382            Some("vue"),
2383            "const count = ref(0);\n",
2384        );
2385        assert_eq!(vue.parser, ParserKind::Structural);
2386        assert!(vue.symbols.iter().any(|symbol| {
2387            symbol.name == "count" && symbol.detail.as_deref() == Some("vue-composition-binding")
2388        }));
2389
2390        let powershell = extract_symbol_graph(
2391            "config/script.txt",
2392            Some("powershell"),
2393            "function Get-Atlas { return 1 }\n",
2394        );
2395        assert_eq!(powershell.parser, ParserKind::Structural);
2396        assert!(powershell.symbols.iter().any(|symbol| {
2397            symbol.name == "Get-Atlas" && symbol.detail.as_deref() == Some("powershell-function")
2398        }));
2399
2400        for (path, source, forbidden_detail) in [
2401            (
2402                "Cargo.toml",
2403                "[package]\nname = \"atlas\"\n",
2404                "cargo-package",
2405            ),
2406            (
2407                "src/App.vue",
2408                "const count = ref(0);\n",
2409                "vue-composition-binding",
2410            ),
2411            (
2412                "scripts/Get-Atlas.ps1",
2413                "function Get-Atlas { return 1 }\n",
2414                "powershell-function",
2415            ),
2416        ] {
2417            let overridden = extract_symbol_graph(path, Some("text"), source);
2418            assert_eq!(overridden.parser, ParserKind::Structural, "{path}");
2419            assert!(overridden.symbols.is_empty(), "{path}");
2420            assert!(
2421                overridden
2422                    .symbols
2423                    .iter()
2424                    .all(|symbol| symbol.detail.as_deref() != Some(forbidden_detail)),
2425                "{path} ignored its supplied language: {:?}",
2426                overridden.symbols
2427            );
2428        }
2429
2430        let cargo_lock_override = extract_symbol_graph(
2431            "Cargo.toml",
2432            Some("cargo-lock"),
2433            "[[package]]\nname = \"atlas-lock\"\nversion = \"1.0.0\"\n",
2434        );
2435        assert!(cargo_lock_override.symbols.iter().any(|symbol| {
2436            symbol.kind == SymbolKind::Dependency && symbol.name == "atlas-lock"
2437        }));
2438    }
2439
2440    #[test]
2441    fn unavailable_symbol_owner_does_not_run_fallback_extraction() {
2442        let graph = extract_symbol_graph(
2443            "README.md",
2444            Some("markdown"),
2445            "pub fn forged_symbol() {}\nclass ForgedType {}\n",
2446        );
2447
2448        assert_eq!(graph.parser, ParserKind::Structural);
2449        assert!(graph.symbols.is_empty());
2450        assert!(graph.relations.is_empty());
2451    }
2452
2453    #[test]
2454    fn missing_language_preserves_specialized_symbol_path_inference() {
2455        let cargo = extract_symbol_graph("Cargo.toml", None, "[package]\nname = \"atlas\"\n");
2456        assert_eq!(cargo.parser, ParserKind::Manifest);
2457        assert!(
2458            cargo
2459                .symbols
2460                .iter()
2461                .any(|symbol| symbol.kind == SymbolKind::Package)
2462        );
2463
2464        let vue = extract_symbol_graph("src/App.vue", None, "const count = ref(0);\n");
2465        assert_eq!(vue.parser, ParserKind::Structural);
2466        assert!(
2467            vue.symbols
2468                .iter()
2469                .any(|symbol| { symbol.detail.as_deref() == Some("vue-composition-binding") })
2470        );
2471
2472        let powershell = extract_symbol_graph(
2473            "scripts/Get-Atlas.ps1",
2474            None,
2475            "function Get-Atlas { return 1 }\n",
2476        );
2477        assert_eq!(powershell.parser, ParserKind::Structural);
2478        assert!(
2479            powershell
2480                .symbols
2481                .iter()
2482                .any(|symbol| { symbol.detail.as_deref() == Some("powershell-function") })
2483        );
2484    }
2485
2486    #[test]
2487    fn extracts_rust_symbols_and_calls() {
2488        let source = r"
2489use std::fs;
2490
2491pub struct Atlas;
2492
2493impl Atlas {
2494    /// Run the atlas scan.
2495    pub fn scan(&self) {
2496        helper();
2497    }
2498}
2499
2500fn helper() {}
2501";
2502        let graph = extract_symbol_graph("src/lib.rs", Some("rust"), source);
2503        assert!(
2504            graph.symbols.iter().any(|symbol| {
2505                symbol.kind == SymbolKind::Struct && symbol.name.contains("Atlas")
2506            })
2507        );
2508        assert!(graph.symbols.iter().any(|symbol| {
2509            symbol.kind == SymbolKind::Function && symbol.name.contains("helper")
2510        }));
2511        assert!(graph.symbols.iter().any(|symbol| {
2512            symbol.kind == SymbolKind::Method
2513                && symbol.name.contains("scan")
2514                && symbol.parent.as_deref() == Some("Atlas")
2515                && symbol.exported
2516                && symbol.documentation.as_deref() == Some("Run the atlas scan.")
2517        }));
2518        assert!(graph.relations.iter().any(|relation| {
2519            relation.kind == RelationKind::Calls && relation.target_name.contains("helper")
2520        }));
2521    }
2522
2523    #[test]
2524    fn declaration_signatures_ignore_location_formatting_and_body_edits() {
2525        let before = extract_symbol_graph(
2526            "src/lib.rs",
2527            Some("rust"),
2528            "struct Atlas;\nimpl Atlas { pub fn run(&self, value: i32) -> i32 { value + 1 } }\n",
2529        );
2530        let after = extract_symbol_graph(
2531            "src/lib.rs",
2532            Some("rust"),
2533            "\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",
2534        );
2535        let before = tree_symbol(&before, SymbolKind::Method, "run", Some("Atlas"), "i32");
2536        let after = tree_symbol(&after, SymbolKind::Method, "run", Some("Atlas"), "i32");
2537        assert!(before.is_some() && after.is_some());
2538        let (Some(before), Some(after)) = (before, after) else {
2539            return;
2540        };
2541        assert_ne!(before.line_start, after.line_start);
2542        assert_eq!(before.signature, after.signature);
2543        assert!(!after.signature.contains("saturating_mul"));
2544
2545        let first = extract_symbol_graph(
2546            "src/run.ts",
2547            Some("typescript"),
2548            "export const run = (value: number): number => { return value + 1; };",
2549        );
2550        let changed = extract_symbol_graph(
2551            "src/run.ts",
2552            Some("typescript"),
2553            "export const run = (value: number): number => { return value * 20; };",
2554        );
2555        let first = tree_symbol(&first, SymbolKind::Function, "run", None, "number");
2556        let changed = tree_symbol(&changed, SymbolKind::Function, "run", None, "number");
2557        assert!(first.is_some() && changed.is_some());
2558        let (Some(first), Some(changed)) = (first, changed) else {
2559            return;
2560        };
2561        assert_eq!(first.signature, changed.signature);
2562        assert!(!first.signature.contains("return"));
2563    }
2564
2565    #[test]
2566    fn declaration_value_signatures_ignore_initializers_but_retain_declared_types() {
2567        for (path, language, before, initializer_changed, type_changed, name, declared_type) in [
2568            (
2569                "src/lib.rs",
2570                "rust",
2571                "pub const LIMIT: usize = 10;",
2572                "pub const LIMIT: usize = calculate_limit();",
2573                "pub const LIMIT: u64 = 10;",
2574                "LIMIT",
2575                "usize",
2576            ),
2577            (
2578                "src/config.ts",
2579                "typescript",
2580                "export const retryCount: number = 3;",
2581                "export const retryCount: number = calculateRetries();",
2582                "export const retryCount: string = '3';",
2583                "retryCount",
2584                "number",
2585            ),
2586        ] {
2587            let before = extract_symbol_graph(path, Some(language), before);
2588            let initializer_changed =
2589                extract_symbol_graph(path, Some(language), initializer_changed);
2590            let type_changed = extract_symbol_graph(path, Some(language), type_changed);
2591            let before = tree_symbol(&before, SymbolKind::Value, name, None, declared_type);
2592            let initializer_changed = tree_symbol(
2593                &initializer_changed,
2594                SymbolKind::Value,
2595                name,
2596                None,
2597                declared_type,
2598            );
2599            let type_changed = tree_symbol(&type_changed, SymbolKind::Value, name, None, "");
2600            assert!(before.is_some() && initializer_changed.is_some() && type_changed.is_some());
2601            let (Some(before), Some(initializer_changed), Some(type_changed)) =
2602                (before, initializer_changed, type_changed)
2603            else {
2604                return;
2605            };
2606            assert_eq!(before.signature, initializer_changed.signature);
2607            assert_ne!(before.signature, type_changed.signature);
2608            assert!(!initializer_changed.signature.contains("calculate"));
2609        }
2610    }
2611
2612    #[test]
2613    fn declaration_identity_material_disambiguates_overloads_and_parents() {
2614        let rust = extract_symbol_graph(
2615            "src/lib.rs",
2616            Some("rust"),
2617            "struct Left; impl Left { fn run(&self, value: i32) {} }\nstruct Right; impl Right { fn run(&self, value: i32) {} }\n",
2618        );
2619        let left = tree_symbol(&rust, SymbolKind::Method, "run", Some("Left"), "i32");
2620        let right = tree_symbol(&rust, SymbolKind::Method, "run", Some("Right"), "i32");
2621        assert!(left.is_some() && right.is_some());
2622        let (Some(left), Some(right)) = (left, right) else {
2623            return;
2624        };
2625        assert_eq!(left.signature, right.signature);
2626        assert_ne!(left.parent, right.parent);
2627
2628        let java = extract_symbol_graph(
2629            "src/Runner.java",
2630            Some("java"),
2631            "class Runner { int run(\n int value\n) { return value; } String run(\n String value\n) { return value; } }",
2632        );
2633        let numeric = tree_symbol(
2634            &java,
2635            SymbolKind::Method,
2636            "run",
2637            Some("Runner"),
2638            "int value",
2639        );
2640        let textual = tree_symbol(
2641            &java,
2642            SymbolKind::Method,
2643            "run",
2644            Some("Runner"),
2645            "String value",
2646        );
2647        assert!(numeric.is_some() && textual.is_some());
2648        let (Some(numeric), Some(textual)) = (numeric, textual) else {
2649            return;
2650        };
2651        assert_ne!(numeric.signature, textual.signature);
2652        assert_eq!(numeric.parent, textual.parent);
2653    }
2654
2655    #[test]
2656    fn native_parser_graph_survives_when_empty() {
2657        let graph = extract_symbol_graph("src/empty.rs", Some("rust"), "// comment only\n");
2658        assert_eq!(graph.parser, ParserKind::TreeSitter);
2659        assert!(graph.symbols.is_empty());
2660        assert!(graph.relations.is_empty());
2661    }
2662
2663    #[test]
2664    fn native_parser_ignores_fallback_patterns_inside_comments() {
2665        let graph = extract_symbol_graph(
2666            "src/commented.rs",
2667            Some("rust"),
2668            "/*\ndef leaked():\n    pass\nfunction leaked() {}\nimport x\n*/\n",
2669        );
2670        assert_eq!(graph.parser, ParserKind::TreeSitter);
2671        assert!(graph.symbols.is_empty());
2672        assert!(graph.relations.is_empty());
2673
2674        let graph = extract_symbol_graph(
2675            "src/commented.ts",
2676            Some("typescript"),
2677            "/*\ndef leaked():\n    pass\nfunction leaked() {}\nimport x\n*/\n",
2678        );
2679        assert_eq!(graph.parser, ParserKind::TreeSitter);
2680        assert!(graph.symbols.is_empty());
2681        assert!(graph.relations.is_empty());
2682    }
2683
2684    #[test]
2685    fn native_empty_graph_keeps_fallback_rescue() {
2686        let graph = extract_symbol_graph(
2687            "src/misdetected.rs",
2688            Some("rust"),
2689            "def rescued():\n    return 1\n",
2690        );
2691        assert_eq!(graph.parser, ParserKind::Fallback);
2692        assert!(graph.symbols.iter().any(|symbol| {
2693            symbol.name == "rescued"
2694                && symbol.kind == SymbolKind::Function
2695                && symbol.detail.as_deref() == Some("fallback-python-function")
2696        }));
2697    }
2698
2699    #[test]
2700    fn fallback_preserves_full_powershell_function_names() {
2701        let graph = extract_symbol_graph(
2702            "scripts/install-runtime.ps1",
2703            Some("powershell"),
2704            "class RuntimeConfig {\n}\nfunction Resolve-DefaultProjectRoot {\n}\nfunction Get-ReleaseRuntimeInstallPath {\n}\nfunction Install-ReleaseBinary {\n}\n",
2705        );
2706        assert_eq!(graph.parser, ParserKind::Structural);
2707        assert!(
2708            graph.symbols.iter().any(|symbol| {
2709                symbol.kind == SymbolKind::Class
2710                    && symbol.name == "RuntimeConfig"
2711                    && symbol.detail.as_deref() == Some("powershell-class")
2712            }),
2713            "missing PowerShell class symbol: {:?}",
2714            graph.symbols
2715        );
2716
2717        for name in [
2718            "Resolve-DefaultProjectRoot",
2719            "Get-ReleaseRuntimeInstallPath",
2720            "Install-ReleaseBinary",
2721        ] {
2722            assert!(
2723                graph.symbols.iter().any(|symbol| {
2724                    symbol.kind == SymbolKind::Function
2725                        && symbol.name == name
2726                        && symbol.detail.as_deref() == Some("powershell-function")
2727                }),
2728                "missing full PowerShell function name {name}: {:?}",
2729                graph.symbols
2730            );
2731        }
2732        assert!(
2733            !graph
2734                .symbols
2735                .iter()
2736                .any(|symbol| symbol.name == "Resolve" || symbol.name == "Install"),
2737            "PowerShell function names must not be truncated to verbs: {:?}",
2738            graph.symbols
2739        );
2740    }
2741
2742    #[test]
2743    fn extracts_typescript_symbols() {
2744        let source = r#"
2745import { readFile } from "fs";
2746export interface Reader { read(): string }
2747export class AtlasReader {
2748  read() { return readFile; }
2749}
2750export function createReader() { return new AtlasReader(); }
2751export const createWriter = () => createReader();
2752"#;
2753        let graph = extract_symbol_graph("src/index.ts", Some("typescript"), source);
2754        assert!(graph.symbols.iter().any(|symbol| {
2755            symbol.kind == SymbolKind::Interface
2756                && symbol.name.contains("Reader")
2757                && symbol.exported
2758        }));
2759        assert!(graph.symbols.iter().any(|symbol| {
2760            symbol.kind == SymbolKind::Class
2761                && symbol.name.contains("AtlasReader")
2762                && symbol.exported
2763        }));
2764        assert!(graph.symbols.iter().any(|symbol| {
2765            symbol.kind == SymbolKind::Function
2766                && symbol.name.contains("createReader")
2767                && symbol.exported
2768        }));
2769        assert!(graph.symbols.iter().any(|symbol| {
2770            symbol.kind == SymbolKind::Function && symbol.name == "createWriter" && symbol.exported
2771        }));
2772        assert!(graph.symbols.iter().any(|symbol| {
2773            symbol.kind == SymbolKind::Method
2774                && symbol.name == "read"
2775                && symbol.parent.as_deref() == Some("AtlasReader")
2776        }));
2777        assert!(graph.relations.iter().any(|relation| {
2778            relation.kind == RelationKind::Imports && relation.target_name.contains("readFile")
2779        }));
2780    }
2781
2782    #[test]
2783    fn typescript_nested_locals_do_not_inherit_exported_parent() {
2784        let source = r#"
2785export function useAtlas() {
2786  type LocalMode = "fast" | "safe";
2787  const localCache = new Map<string, string>();
2788  const computeLocal = () => localCache.size;
2789  return computeLocal();
2790}
2791"#;
2792        let graph = extract_symbol_graph("src/use-atlas.ts", Some("typescript"), source);
2793        assert!(graph.symbols.iter().any(|symbol| {
2794            symbol.kind == SymbolKind::Function && symbol.name == "useAtlas" && symbol.exported
2795        }));
2796        assert!(graph.symbols.iter().any(|symbol| {
2797            symbol.name == "LocalMode"
2798                && symbol.parent.as_deref() == Some("useAtlas")
2799                && !symbol.exported
2800        }));
2801        for nested_value in ["localCache", "computeLocal"] {
2802            assert!(
2803                graph.symbols.iter().any(|symbol| {
2804                    symbol.name == nested_value
2805                        && symbol.kind == SymbolKind::Value
2806                        && symbol.parent.as_deref() == Some("useAtlas")
2807                        && !symbol.exported
2808                }),
2809                "nested value {nested_value} should remain indexed with parent and no export"
2810            );
2811        }
2812    }
2813
2814    #[test]
2815    fn javascript_summary_symbols_ignore_locals_and_iife_constants() {
2816        let source = r#"
2817import path from "node:path";
2818import { createHash } from "node:crypto";
2819
2820const DATA_DIRECTORY = path.resolve("app/public/data");
2821const OUTPUT_FILE = path.join(DATA_DIRECTORY, "datasets.manifest.json");
2822const CACHE_NAME = (() => `sw-${Date.now()}`)();
2823
2824function sha256(value) {
2825  return createHash("sha256").update(value).digest("hex");
2826}
2827
2828async function readDatasetEntry(filePath) {
2829  return sha256(filePath);
2830}
2831
2832async function main() {
2833  const datasetEntries = await Promise.all(["a"].map((file) => readDatasetEntry(file)));
2834  const versionSeed = datasetEntries.map((entry) => entry.id).join("\n");
2835  return versionSeed;
2836}
2837"#;
2838        let graph = extract_symbol_graph("scripts/generate.mjs", Some("javascript"), source);
2839        for name in ["sha256", "readDatasetEntry", "main"] {
2840            assert!(
2841                graph
2842                    .symbols
2843                    .iter()
2844                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == name),
2845                "missing top-level function {name}"
2846            );
2847        }
2848        for name in ["DATA_DIRECTORY", "OUTPUT_FILE", "CACHE_NAME"] {
2849            assert!(
2850                graph
2851                    .symbols
2852                    .iter()
2853                    .any(|symbol| symbol.kind == SymbolKind::Value && symbol.name == name),
2854                "missing top-level constant {name}"
2855            );
2856            assert!(
2857                !graph
2858                    .symbols
2859                    .iter()
2860                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == name),
2861                "constant {name} must not be promoted to a function"
2862            );
2863        }
2864        for local in ["datasetEntries", "versionSeed"] {
2865            assert!(
2866                graph
2867                    .symbols
2868                    .iter()
2869                    .any(|symbol| symbol.kind == SymbolKind::Value && symbol.name == local),
2870                "local binding {local} should remain indexed as a nested value"
2871            );
2872            assert!(
2873                !graph
2874                    .symbols
2875                    .iter()
2876                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == local),
2877                "local binding {local} must not become a function"
2878            );
2879        }
2880    }
2881
2882    #[test]
2883    fn javascript_object_literal_methods_are_not_file_level_methods() {
2884        let source = r"
2885const stub = {
2886  addListener() {},
2887  removeListener() {},
2888  nested: {
2889    addEventListener() {},
2890    removeEventListener() {}
2891  }
2892};
2893
2894class Harness {
2895  run() {}
2896}
2897";
2898        let graph = extract_symbol_graph("tests/browser.spec.js", Some("javascript"), source);
2899        for object_method in [
2900            "addListener",
2901            "removeListener",
2902            "addEventListener",
2903            "removeEventListener",
2904        ] {
2905            assert!(
2906                !graph
2907                    .symbols
2908                    .iter()
2909                    .any(|symbol| symbol.name == object_method),
2910                "object literal method {object_method} must not become a file-level method"
2911            );
2912        }
2913        assert!(
2914            graph
2915                .symbols
2916                .iter()
2917                .any(|symbol| { symbol.kind == SymbolKind::Class && symbol.name == "Harness" })
2918        );
2919        assert!(graph.symbols.iter().any(|symbol| {
2920            symbol.kind == SymbolKind::Method
2921                && symbol.name == "run"
2922                && symbol.parent.as_deref() == Some("Harness")
2923        }));
2924    }
2925
2926    #[test]
2927    fn javascript_exported_object_literal_methods_remain_indexed() {
2928        let source = r"
2929export const api = {
2930  list() {},
2931  nested: {
2932    refresh() {}
2933  }
2934};
2935
2936module.exports = {
2937  boot() {}
2938};
2939";
2940        let graph = extract_symbol_graph("src/api.js", Some("javascript"), source);
2941        assert!(graph.symbols.iter().any(|symbol| {
2942            symbol.kind == SymbolKind::Method
2943                && symbol.name == "list"
2944                && symbol.parent.as_deref() == Some("api")
2945                && symbol.exported
2946        }));
2947        assert!(graph.symbols.iter().any(|symbol| {
2948            symbol.kind == SymbolKind::Method
2949                && symbol.name == "refresh"
2950                && symbol.parent.as_deref() == Some("api.nested")
2951                && symbol.exported
2952        }));
2953        assert!(graph.symbols.iter().any(|symbol| {
2954            symbol.kind == SymbolKind::Method
2955                && symbol.name == "boot"
2956                && symbol.parent.as_deref() == Some("module.exports")
2957                && symbol.exported
2958        }));
2959    }
2960
2961    #[test]
2962    fn javascript_direct_callable_constants_remain_functions() {
2963        let source = r#"
2964export const createThing = () => ({ kind: "thing" });
2965const helper = function helperFactory() { return createThing(); };
2966"#;
2967        let graph = extract_symbol_graph("src/factory.js", Some("javascript"), source);
2968        for name in ["createThing", "helper"] {
2969            assert!(
2970                graph
2971                    .symbols
2972                    .iter()
2973                    .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == name),
2974                "callable constant {name} should remain function-like"
2975            );
2976        }
2977    }
2978
2979    #[test]
2980    fn file_purpose_docblock_is_not_symbol_documentation() {
2981        let source = r#"/**
2982 * Purpose: Choose a fresher catalog start so repeated app opens avoid the same opening items.
2983 */
2984import type { CatalogItem } from "@/types/catalog";
2985export function applyLaunchFreshness() {}
2986"#;
2987        let graph = extract_symbol_graph("src/launch-freshness.ts", Some("typescript"), source);
2988        assert!(
2989            graph
2990                .symbols
2991                .iter()
2992                .any(|symbol| symbol.kind == SymbolKind::Import && symbol.documentation.is_none())
2993        );
2994        assert!(
2995            graph
2996                .symbols
2997                .iter()
2998                .any(|symbol| symbol.name == "applyLaunchFreshness"
2999                    && symbol.documentation.is_none())
3000        );
3001    }
3002
3003    #[test]
3004    fn boundary_truncates_long_import_snippet() {
3005        let truncated = super::truncate_chars_at_boundary(
3006            "import type { DigestDraft, DeliveryChannel, CatalogDatasetItem } from \"@/catalog\";",
3007            56,
3008        );
3009
3010        assert_eq!(truncated, "import type { DigestDraft, DeliveryChannel...");
3011    }
3012
3013    #[test]
3014    fn import_specific_comment_remains_import_documentation() {
3015        let source = r#"/** Loads a required browser polyfill. */
3016import "./polyfill";
3017"#;
3018        let graph = extract_symbol_graph("src/polyfills.ts", Some("typescript"), source);
3019        assert!(
3020            graph.symbols.iter().any(|symbol| {
3021                symbol.kind == SymbolKind::Import
3022                    && symbol.documentation.as_deref() == Some("Loads a required browser polyfill.")
3023            }),
3024            "import-specific documentation should remain attached to the import symbol"
3025        );
3026    }
3027
3028    #[test]
3029    fn extracts_vue_composition_bindings_from_script_setup() {
3030        let source = r#"
3031<template><article>{{ currentPriceLabel }}</article></template>
3032<script setup lang="ts">
3033import { computed, ref } from "vue";
3034
3035const props = withDefaults(defineProps<{
3036  title: string;
3037}>(), { title: "Product" });
3038const emit = defineEmits<{
3039  select: [id: string];
3040}>();
3041const productTitleId = computed(() => props.title.toLowerCase());
3042const currentPriceLabel = computed(() => `$${props.title}`);
3043const retryCount = ref(0);
3044</script>
3045"#;
3046        let graph = extract_symbol_graph("src/ProductPanel.vue", Some("vue"), source);
3047        for expected in [
3048            "props",
3049            "emit",
3050            "productTitleId",
3051            "currentPriceLabel",
3052            "retryCount",
3053        ] {
3054            assert!(
3055                graph.symbols.iter().any(|symbol| {
3056                    symbol.kind == SymbolKind::Value
3057                        && symbol.name == expected
3058                        && symbol.detail.as_deref() == Some("vue-composition-binding")
3059                        && symbol.parser == ParserKind::Structural
3060                }),
3061                "missing Vue Composition API binding {expected}"
3062            );
3063        }
3064        assert_eq!(graph.parser, ParserKind::Structural);
3065        assert!(graph.relations.iter().any(|relation| {
3066            relation.kind == RelationKind::Imports
3067                && relation.target_name.contains("computed")
3068                && relation.parser == ParserKind::Structural
3069        }));
3070        assert!(
3071            graph
3072                .symbols
3073                .iter()
3074                .all(|symbol| symbol.parser == ParserKind::Structural)
3075        );
3076        assert!(graph.relations.iter().any(|relation| {
3077            relation.kind == RelationKind::Calls
3078                && relation.target_name == "computed"
3079                && relation.parser == ParserKind::TreeSitter
3080        }));
3081    }
3082
3083    #[test]
3084    fn extracts_embedded_html_and_svelte_facts_at_host_lines() {
3085        let html = r#"<main>not source</main>
3086<script lang="ts">
3087export interface ProductRecord { id: string }
3088export function loadProduct() { return ProductRecord; }
3089</script>
3090"#;
3091        let graph = extract_symbol_graph("public/index.html", Some("html"), html);
3092        assert_eq!(graph.parser, ParserKind::Structural);
3093        assert!(graph.symbols.iter().any(|symbol| {
3094            symbol.name == "ProductRecord"
3095                && symbol.kind == SymbolKind::Interface
3096                && symbol.line_start == 3
3097                && symbol.parser == ParserKind::TreeSitter
3098                && symbol.language.as_deref() == Some("typescript")
3099        }));
3100        assert!(graph.symbols.iter().any(|symbol| {
3101            symbol.name == "loadProduct"
3102                && symbol.line_start == 4
3103                && symbol.parser == ParserKind::TreeSitter
3104        }));
3105
3106        let svelte = r#"<h1>{title}</h1>
3107<script lang="ts">
3108export interface PageData { title: string }
3109</script>
3110"#;
3111        let graph = extract_symbol_graph("src/Page.svelte", Some("svelte"), svelte);
3112        assert_eq!(graph.parser, ParserKind::Fallback);
3113        assert!(graph.symbols.iter().any(|symbol| {
3114            symbol.name == "PageData"
3115                && symbol.kind == SymbolKind::Interface
3116                && symbol.line_start == 3
3117                && symbol.parser == ParserKind::TreeSitter
3118        }));
3119    }
3120
3121    #[test]
3122    fn embedded_hosts_ignore_external_and_retain_only_safe_facts_on_incomplete_input() {
3123        for source in [
3124            "<script src=\"external.js\">export function forged() {}</script>",
3125            "<script lang=\"ts\">export function incomplete() {}",
3126        ] {
3127            let graph = extract_symbol_graph("public/index.html", Some("html"), source);
3128            assert!(
3129                graph.symbols.is_empty(),
3130                "unexpected symbols: {:?}",
3131                graph.symbols
3132            );
3133            assert!(
3134                graph.relations.is_empty(),
3135                "unexpected relations: {:?}",
3136                graph.relations
3137            );
3138        }
3139
3140        let source = "<script>export function admitted() {}</script>"
3141            .repeat(super::semantic::embedded_source::MAX_EMBEDDED_SCRIPT_REGIONS + 1);
3142        let graph = extract_symbol_graph("public/index.html", Some("html"), &source);
3143        assert!(graph.symbols.iter().any(|symbol| symbol.name == "admitted"));
3144        assert_eq!(graph.parser, ParserKind::Structural);
3145    }
3146
3147    #[test]
3148    fn purpose_header_mask_preserves_utf8_byte_offsets_for_embedded_hosts() {
3149        let source = concat!(
3150            "<!-- Purpose: Grüße and routing -->\n",
3151            "<script lang=\"ts\">export const admitted = 1;</script>\n"
3152        );
3153        let masked = content_without_leading_purpose_header(source);
3154        assert_eq!(masked.len(), source.len());
3155        assert_eq!(masked.find("<script"), source.find("<script"));
3156
3157        let graph = extract_symbol_graph("public/index.html", Some("html"), source);
3158        assert!(graph.symbols.iter().any(|symbol| {
3159            symbol.name == "admitted"
3160                && symbol.line_start == 2
3161                && symbol.parser == ParserKind::TreeSitter
3162        }));
3163    }
3164
3165    #[test]
3166    fn vue_sfc_preserves_fallback_declarations() {
3167        let source = r#"
3168<script lang="ts">
3169export function submitOrder() {
3170  return true;
3171}
3172
3173class Store {
3174}
3175</script>
3176<script setup lang="ts">
3177import { ref } from "vue";
3178const selected = ref(false);
3179</script>
3180"#;
3181        let graph = extract_symbol_graph("src/CheckoutPanel.vue", Some("vue"), source);
3182
3183        assert!(graph.symbols.iter().any(|symbol| {
3184            symbol.kind == SymbolKind::Value
3185                && symbol.name == "selected"
3186                && symbol.detail.as_deref() == Some("vue-composition-binding")
3187                && symbol.parser == ParserKind::Structural
3188        }));
3189        assert!(graph.symbols.iter().any(|symbol| {
3190            symbol.kind == SymbolKind::Function
3191                && symbol.name == "submitOrder"
3192                && symbol.detail.as_deref() == Some("fallback-js-function")
3193                && symbol.parser == ParserKind::Fallback
3194        }));
3195        assert!(graph.symbols.iter().any(|symbol| {
3196            symbol.kind == SymbolKind::Class
3197                && symbol.name == "Store"
3198                && symbol.detail.as_deref() == Some("fallback-class")
3199                && symbol.parser == ParserKind::Fallback
3200        }));
3201    }
3202
3203    #[test]
3204    fn vue_sfc_preserves_fallback_declarations_when_bindings_exceed_cap() {
3205        let mut source = String::from(
3206            r#"
3207<script setup lang="ts">
3208export function submitOrder() {
3209  return true;
3210}
3211
3212class Store {
3213}
3214"#,
3215        );
3216        for index in 0..(MAX_SYMBOLS_PER_FILE + 50) {
3217            source.push_str("const value");
3218            source.push_str(&index.to_string());
3219            source.push_str(" = ref(false);\n");
3220        }
3221        source.push_str("</script>\n");
3222
3223        let graph = extract_symbol_graph("src/LargePanel.vue", Some("vue"), &source);
3224
3225        assert!(graph.symbols.iter().any(|symbol| {
3226            symbol.kind == SymbolKind::Function
3227                && symbol.name == "submitOrder"
3228                && symbol.detail.as_deref() == Some("fallback-js-function")
3229        }));
3230        assert!(graph.symbols.iter().any(|symbol| {
3231            symbol.kind == SymbolKind::Class
3232                && symbol.name == "Store"
3233                && symbol.detail.as_deref() == Some("fallback-class")
3234        }));
3235        assert!(graph.symbols.iter().any(|symbol| {
3236            symbol.kind == SymbolKind::Value
3237                && symbol.name == "value0"
3238                && symbol.detail.as_deref() == Some("vue-composition-binding")
3239                && symbol.parser == ParserKind::Structural
3240        }));
3241    }
3242
3243    #[test]
3244    fn vue_composition_binding_detection_requires_macro_call_boundary() {
3245        let source = r#"
3246<script setup lang="ts">
3247const data = refreshData();
3248const value = computedValue();
3249const typed = ref<string>("ok");
3250const delayed = computed (() => typed.value);
3251const props = withDefaults(defineProps<{ title: string }>(), { title: "Product" });
3252</script>
3253"#;
3254        let graph = extract_symbol_graph("src/Widget.vue", Some("vue"), source);
3255
3256        for absent in ["data", "value"] {
3257            assert!(
3258                graph.symbols.iter().all(|symbol| symbol.name != absent),
3259                "ordinary function call {absent} was incorrectly treated as a Vue binding"
3260            );
3261        }
3262        for expected in ["typed", "delayed", "props"] {
3263            assert!(
3264                graph.symbols.iter().any(|symbol| {
3265                    symbol.kind == SymbolKind::Value
3266                        && symbol.name == expected
3267                        && symbol.detail.as_deref() == Some("vue-composition-binding")
3268                }),
3269                "missing Vue macro binding {expected}"
3270            );
3271        }
3272    }
3273
3274    #[test]
3275    fn extracts_python_docstrings() {
3276        let source = r#"
3277class Builder:
3278    """Builds atlas state."""
3279
3280    def build(self):
3281        """Build the atlas."""
3282        return "atlas"
3283"#;
3284        let graph = extract_symbol_graph("src/builder.py", Some("python"), source);
3285        assert!(graph.symbols.iter().any(|symbol| {
3286            symbol.kind == SymbolKind::Class
3287                && symbol.name == "Builder"
3288                && symbol.documentation.as_deref() == Some("Builds atlas state.")
3289        }));
3290        assert!(graph.symbols.iter().any(|symbol| {
3291            symbol.kind == SymbolKind::Method
3292                && symbol.name == "build"
3293                && symbol.documentation.as_deref() == Some("Build the atlas.")
3294                && symbol.parent.as_deref() == Some("Builder")
3295        }));
3296    }
3297
3298    #[test]
3299    fn extracts_java_package_classes_methods_and_calls() {
3300        let source = r"
3301package com.example.atlas;
3302
3303public class AtlasService {
3304    public void run() {
3305        helper();
3306    }
3307
3308    private void helper() {}
3309}
3310";
3311        let graph = extract_symbol_graph("src/AtlasService.java", Some("java"), source);
3312        assert!(graph.symbols.iter().any(|symbol| {
3313            symbol.kind == SymbolKind::Module && symbol.name == "com.example.atlas"
3314        }));
3315        assert!(graph.symbols.iter().any(|symbol| {
3316            symbol.kind == SymbolKind::Class && symbol.name == "AtlasService" && symbol.exported
3317        }));
3318        assert!(graph.symbols.iter().any(|symbol| {
3319            symbol.kind == SymbolKind::Method
3320                && symbol.name == "run"
3321                && symbol.parent.as_deref() == Some("AtlasService")
3322                && symbol.exported
3323        }));
3324        assert!(graph.relations.iter().any(|relation| {
3325            relation.kind == RelationKind::Calls && relation.target_name == "helper"
3326        }));
3327    }
3328
3329    #[test]
3330    fn extracts_go_package_functions_methods_and_imports() {
3331        let source = r#"
3332package atlas
3333
3334import "fmt"
3335
3336type Runner struct {}
3337
3338func (r Runner) Run() {
3339    helper()
3340}
3341
3342func helper() {
3343    fmt.Println("ok")
3344}
3345"#;
3346        let graph = extract_symbol_graph("service.go", Some("go"), source);
3347        assert!(
3348            graph
3349                .symbols
3350                .iter()
3351                .any(|symbol| { symbol.kind == SymbolKind::Module && symbol.name == "atlas" })
3352        );
3353        assert!(
3354            graph
3355                .symbols
3356                .iter()
3357                .any(|symbol| { symbol.kind == SymbolKind::Struct && symbol.name == "Runner" })
3358        );
3359        assert!(graph.symbols.iter().any(|symbol| {
3360            symbol.kind == SymbolKind::Method && symbol.name == "Run" && symbol.exported
3361        }));
3362        assert!(
3363            graph
3364                .symbols
3365                .iter()
3366                .any(|symbol| { symbol.kind == SymbolKind::Function && symbol.name == "helper" })
3367        );
3368        assert!(graph.relations.iter().any(|relation| {
3369            relation.kind == RelationKind::Imports && relation.target_name.contains("\"fmt\"")
3370        }));
3371    }
3372
3373    #[test]
3374    fn extracts_csharp_namespace_classes_and_methods() {
3375        let source = r"
3376namespace Atlas.Core;
3377
3378public class Runner
3379{
3380    public void Run()
3381    {
3382        Helper();
3383    }
3384
3385    private void Helper() {}
3386}
3387";
3388        let graph = extract_symbol_graph("Runner.cs", Some("csharp"), source);
3389        assert!(
3390            graph
3391                .symbols
3392                .iter()
3393                .any(|symbol| { symbol.kind == SymbolKind::Module && symbol.name == "Atlas.Core" })
3394        );
3395        assert!(graph.symbols.iter().any(|symbol| {
3396            symbol.kind == SymbolKind::Class && symbol.name == "Runner" && symbol.exported
3397        }));
3398        assert!(graph.symbols.iter().any(|symbol| {
3399            symbol.kind == SymbolKind::Method
3400                && symbol.name == "Run"
3401                && symbol.parent.as_deref() == Some("Runner")
3402                && symbol.exported
3403        }));
3404    }
3405
3406    #[test]
3407    fn extracts_remaining_specialized_language_basics() {
3408        let samples = [
3409            (
3410                "src/main.kt",
3411                "kotlin",
3412                r"
3413package com.example.atlas
3414
3415class Runner {
3416    fun run() {}
3417}
3418",
3419                SymbolKind::Class,
3420                "Runner",
3421            ),
3422            (
3423                "src/main.zig",
3424                "zig",
3425                r"
3426const Runner = struct {
3427    pub fn run(self: Runner) void {}
3428};
3429",
3430                SymbolKind::Function,
3431                "run",
3432            ),
3433            (
3434                "src/main.c",
3435                "c",
3436                r"
3437#include <stdio.h>
3438int run(void) { return 0; }
3439",
3440                SymbolKind::Function,
3441                "run",
3442            ),
3443            (
3444                "src/main.cpp",
3445                "cpp",
3446                r"
3447class Runner {
3448public:
3449    void run() {}
3450};
3451",
3452                SymbolKind::Class,
3453                "Runner",
3454            ),
3455            (
3456                "src/UserManager.m",
3457                "objective-c",
3458                r"
3459@interface UserManager
3460- (void)run;
3461@end
3462@implementation UserManager
3463- (void)run {}
3464@end
3465",
3466                SymbolKind::Class,
3467                "UserManager",
3468            ),
3469        ];
3470        for (path, language, source, kind, name) in samples {
3471            let graph = extract_symbol_graph(path, Some(language), source);
3472            assert!(
3473                graph
3474                    .symbols
3475                    .iter()
3476                    .any(|symbol| symbol.kind == kind && symbol.name.contains(name)),
3477                "expected {language} sample to contain {kind:?} {name}, got {:?}",
3478                graph.symbols
3479            );
3480        }
3481    }
3482
3483    #[test]
3484    fn normalizes_language_specific_edge_summaries() {
3485        let kotlin = extract_symbol_graph(
3486            "src/KotlinRunner.kt",
3487            Some("kotlin"),
3488            r"
3489package com.example.atlas
3490class KotlinRunner { fun run() { helper() } private fun helper() {} }
3491",
3492        );
3493        assert!(kotlin.symbols.iter().any(|symbol| {
3494            symbol.kind == SymbolKind::Module && symbol.name == "com.example.atlas"
3495        }));
3496        assert!(
3497            kotlin.symbols.iter().any(|symbol| {
3498                symbol.kind == SymbolKind::Class && symbol.name == "KotlinRunner"
3499            })
3500        );
3501        assert!(kotlin.symbols.iter().any(|symbol| {
3502            symbol.kind == SymbolKind::Method
3503                && symbol.name == "run"
3504                && symbol.parent.as_deref() == Some("KotlinRunner")
3505        }));
3506
3507        for path in ["src/Worker.kt", "scripts/tasks.kts"] {
3508            let ordinary_kotlin = extract_symbol_graph(
3509                path,
3510                Some("kotlin"),
3511                r#"
3512class Worker {
3513    fun queue(tasks: TaskContainer) {
3514        tasks.register("notGradleTask")
3515    }
3516}
3517"#,
3518            );
3519            assert!(
3520                !ordinary_kotlin.symbols.iter().any(|symbol| {
3521                    symbol.name == "notGradleTask"
3522                        || symbol.detail.as_deref() == Some("gradle-kotlin-dsl-task")
3523                }),
3524                "ordinary Kotlin path {path} should not emit Gradle task symbols: {:?}",
3525                ordinary_kotlin.symbols
3526            );
3527        }
3528
3529        let gradle_kotlin = extract_symbol_graph(
3530            "build.gradle.kts",
3531            Some("kotlin"),
3532            r#"
3533import org.springframework.boot.gradle.tasks.run.BootRun
3534
3535fun loadDotEnv() = emptyMap<String, String>()
3536
3537tasks.register<BootRun>("bootRunE2E") {
3538    group = "verification"
3539}
3540
3541val verifyAtlas by tasks.registering {
3542    group = "verification"
3543}
3544
3545tasks {
3546    register<Copy>("copyE2EReports") {
3547        group = "verification"
3548    }
3549}
3550
3551task("publishKtsE2E") {}
3552"#,
3553        );
3554        assert_eq!(gradle_kotlin.parser, ParserKind::TreeSitter);
3555        for task in [
3556            "bootRunE2E",
3557            "copyE2EReports",
3558            "verifyAtlas",
3559            "publishKtsE2E",
3560        ] {
3561            assert!(gradle_kotlin.symbols.iter().any(|symbol| {
3562                symbol.kind == SymbolKind::Function
3563                    && symbol.name == task
3564                    && symbol.detail.as_deref() == Some("gradle-kotlin-dsl-task")
3565            }));
3566        }
3567        let fallback_gradle_kotlin = extract_fallback_graph(
3568            "build.gradle.kts",
3569            Some("kotlin"),
3570            r#"
3571tasks.register<BootRun>("bootRunE2E") {
3572    group = "verification"
3573}
3574
3575fun broken(
3576"#,
3577        );
3578        assert_eq!(fallback_gradle_kotlin.parser, ParserKind::Fallback);
3579        assert!(
3580            fallback_gradle_kotlin.symbols.iter().any(|symbol| {
3581                symbol.kind == SymbolKind::Function
3582                    && symbol.name == "bootRunE2E"
3583                    && symbol.detail.as_deref() == Some("gradle-kotlin-dsl-task")
3584            }),
3585            "fallback Gradle KTS graph should retain task symbols: {:?}",
3586            fallback_gradle_kotlin.symbols
3587        );
3588
3589        let gradle_groovy = extract_symbol_graph(
3590            "build.gradle",
3591            Some("groovy"),
3592            r"
3593plugins { id 'java' }
3594
3595tasks.register('bootRunSmoke', BootRun) {
3596    group = 'verification'
3597}
3598
3599task cleanE2E(type: Delete) {}
3600
3601tasks {
3602    create('copyGroovyReports') {
3603        group = 'verification'
3604    }
3605}
3606
3607task('publishE2E') {}
3608",
3609        );
3610        assert_eq!(gradle_groovy.parser, ParserKind::Fallback);
3611        for task in [
3612            "bootRunSmoke",
3613            "cleanE2E",
3614            "copyGroovyReports",
3615            "publishE2E",
3616        ] {
3617            assert!(gradle_groovy.symbols.iter().any(|symbol| {
3618                symbol.kind == SymbolKind::Function
3619                    && symbol.name == task
3620                    && symbol.detail.as_deref() == Some("gradle-groovy-dsl-task")
3621            }));
3622        }
3623
3624        let zig = extract_symbol_graph(
3625            "src/runner.zig",
3626            Some("zig"),
3627            "const ZigRunner = struct { pub fn run(self: ZigRunner) void {} };\n",
3628        );
3629        assert!(
3630            zig.symbols
3631                .iter()
3632                .any(|symbol| { symbol.kind == SymbolKind::Struct && symbol.name == "ZigRunner" })
3633        );
3634        assert!(zig.symbols.iter().any(|symbol| {
3635            symbol.kind == SymbolKind::Method
3636                && symbol.name == "run"
3637                && symbol.parent.as_deref() == Some("ZigRunner")
3638        }));
3639        assert!(
3640            !zig.symbols
3641                .iter()
3642                .any(|symbol| symbol.name.contains("struct {"))
3643        );
3644
3645        let c_graph = extract_symbol_graph(
3646            "src/runner.c",
3647            Some("c"),
3648            "#include <stdio.h>\nint c_run(void) { return 0; }\n",
3649        );
3650        let c_run_count = c_graph
3651            .symbols
3652            .iter()
3653            .filter(|symbol| symbol.kind == SymbolKind::Function && symbol.name == "c_run")
3654            .count();
3655        assert_eq!(c_run_count, 1);
3656        assert!(
3657            c_graph
3658                .symbols
3659                .iter()
3660                .all(|symbol| symbol.documentation.as_deref() != Some("include <stdio.h>"))
3661        );
3662
3663        let cpp_graph = extract_symbol_graph(
3664            "src/runner.cpp",
3665            Some("cpp"),
3666            "class CppRunner { public: void run(); void inline_run() {} };\n",
3667        );
3668        let cpp_run_names = cpp_graph
3669            .symbols
3670            .iter()
3671            .filter(|symbol| symbol.parent.as_deref() == Some("CppRunner"))
3672            .map(|symbol| symbol.name.as_str())
3673            .collect::<Vec<_>>();
3674        assert_eq!(cpp_run_names, vec!["run", "inline_run"]);
3675        assert!(cpp_graph.symbols.iter().all(|symbol| {
3676            symbol.parent.as_deref() != Some("CppRunner") || symbol.kind == SymbolKind::Method
3677        }));
3678
3679        let objc_graph = extract_symbol_graph(
3680            "src/ObjRunner.m",
3681            Some("objective-c"),
3682            r"
3683@interface ObjRunner
3684- (void)run;
3685@end
3686@implementation ObjRunner
3687- (void)run {}
3688@end
3689",
3690        );
3691        assert_eq!(
3692            objc_graph
3693                .symbols
3694                .iter()
3695                .filter(|symbol| symbol.kind == SymbolKind::Class && symbol.name == "ObjRunner")
3696                .count(),
3697            1
3698        );
3699        assert_eq!(
3700            objc_graph
3701                .symbols
3702                .iter()
3703                .filter(|symbol| symbol.kind == SymbolKind::Method && symbol.name == "run")
3704                .count(),
3705            1
3706        );
3707        assert!(
3708            !objc_graph
3709                .symbols
3710                .iter()
3711                .any(|symbol| symbol.kind == SymbolKind::Function && symbol.name == "run")
3712        );
3713        assert!(objc_graph.symbols.iter().any(|symbol| {
3714            symbol.kind == SymbolKind::Method
3715                && symbol.name == "run"
3716                && symbol.signature.contains("run")
3717                && !symbol.signature.contains('{')
3718        }));
3719    }
3720
3721    #[test]
3722    fn extracts_cargo_manifest_symbols() {
3723        let source = r#"
3724[package]
3725name = "projectatlas"
3726
3727[dependencies]
3728tree-sitter = "0.26"
3729serde_json = { workspace = true }
3730serde_alias = { version = "1", package = "serde" }
3731
3732[target.'cfg(windows)'.dependencies]
3733windows-sys = "0.60"
3734"#;
3735        let graph = extract_symbol_graph("Cargo.toml", Some("cargo-manifest"), source);
3736        assert!(
3737            graph.symbols.iter().any(|symbol| {
3738                symbol.kind == SymbolKind::Package && symbol.name == "projectatlas"
3739            })
3740        );
3741        assert!(graph.symbols.iter().any(|symbol| {
3742            symbol.kind == SymbolKind::Dependency && symbol.name == "tree-sitter"
3743        }));
3744        assert!(
3745            graph
3746                .symbols
3747                .iter()
3748                .any(|symbol| { symbol.kind == SymbolKind::Dependency && symbol.name == "serde" })
3749        );
3750        assert!(graph.symbols.iter().any(|symbol| {
3751            symbol.kind == SymbolKind::Dependency && symbol.name == "windows-sys"
3752        }));
3753    }
3754
3755    #[test]
3756    fn cargo_lock_duplicate_package_names_keep_distinct_lines() {
3757        let source = r#"[[package]]
3758name = "windows-sys"
3759version = "0.59.0"
3760
3761[[package]]
3762name = "windows-sys"
3763version = "0.60.0"
3764"#;
3765        let graph = extract_symbol_graph("Cargo.lock", Some("cargo-lock"), source);
3766        let lines = graph
3767            .symbols
3768            .iter()
3769            .filter(|symbol| symbol.kind == SymbolKind::Dependency && symbol.name == "windows-sys")
3770            .map(|symbol| symbol.line_start)
3771            .collect::<Vec<_>>();
3772        assert_eq!(lines, vec![2, 6]);
3773    }
3774
3775    #[test]
3776    fn specialized_language_registry_covers_target_set() {
3777        for expected in [
3778            "rust",
3779            "python",
3780            "javascript",
3781            "typescript",
3782            "java",
3783            "kotlin",
3784            "csharp",
3785            "go",
3786            "objective-c",
3787            "zig",
3788        ] {
3789            assert!(specialized_languages().contains(&expected));
3790        }
3791    }
3792}