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