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