1use std::borrow::Cow;
11use std::collections::{HashMap as StdHashMap, HashSet as StdHashSet};
12use std::io::BufRead;
13use std::path::Path;
14use std::sync::{Arc, LazyLock, OnceLock};
15
16#[cfg(feature = "parallel")]
17use rayon::prelude::*;
18use regex::Regex;
19use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
20use serde::{Deserialize, Serialize};
21
22macro_rules! maybe_par_iter {
24 ($slice:expr) => {{
25 #[cfg(feature = "parallel")]
26 {
27 $slice.par_iter()
28 }
29 #[cfg(not(feature = "parallel"))]
30 {
31 $slice.iter()
32 }
33 }};
34}
35
36use crate::git::types::{FileChange, FileStatus};
37use crate::model::entity::SemanticEntity;
38use crate::parser::import_resolution::{
39 find_import_file, find_import_target, import_source_matches_file, is_js_ts_file,
40 js_ts_import_source_files_from_content, js_ts_named_exports_from_content,
41 sort_import_candidate_files, JS_TS_EXTENSIONS,
42};
43use crate::parser::registry::{resolve_go_method_parent_ids, ParserRegistry};
44use crate::parser::scope_resolve;
45
46#[cfg(not(test))]
47const PARSED_FILE_REUSE_LIMIT: usize = 20_000;
48#[cfg(test)]
49const PARSED_FILE_REUSE_LIMIT: usize = 8;
50#[cfg(not(test))]
51const SCOPE_RESOLVE_FILE_CHUNK_SIZE: usize = 5_000;
52#[cfg(test)]
53const SCOPE_RESOLVE_FILE_CHUNK_SIZE: usize = 3;
54
55#[derive(Clone, Copy)]
56struct ChildRange<'a> {
57 file_path: &'a str,
58 start_line: usize,
59 end_line: usize,
60 start_byte: Option<usize>,
61 end_byte: Option<usize>,
62}
63
64fn build_child_ranges_by_parent<'a>(
65 entities: &'a [SemanticEntity],
66) -> HashMap<&'a str, Vec<ChildRange<'a>>> {
67 let entity_by_id: HashMap<&str, &SemanticEntity> = entities
68 .iter()
69 .map(|entity| (entity.id.as_str(), entity))
70 .collect();
71 let mut line_starts_by_parent: HashMap<&'a str, Vec<usize>> = HashMap::default();
72 let mut child_ranges_by_parent: HashMap<&'a str, Vec<ChildRange<'a>>> = HashMap::default();
73
74 for child in entities {
75 let Some(parent_id) = child.parent_id.as_deref() else {
76 continue;
77 };
78 let (start_byte, end_byte) = entity_by_id
79 .get(parent_id)
80 .and_then(|parent| {
81 let parent_line_starts = line_starts_by_parent
82 .entry(parent_id)
83 .or_insert_with(|| line_start_offsets(&parent.content));
84 child_content_span_in_parent(parent, child, parent_line_starts)
85 })
86 .map_or((None, None), |(start, end)| (Some(start), Some(end)));
87
88 child_ranges_by_parent
89 .entry(parent_id)
90 .or_default()
91 .push(ChildRange {
92 file_path: child.file_path.as_str(),
93 start_line: child.start_line,
94 end_line: child.end_line,
95 start_byte,
96 end_byte,
97 });
98 }
99
100 for child_ranges in child_ranges_by_parent.values_mut() {
101 child_ranges.sort_unstable_by(|left, right| {
102 match (left.start_byte, right.start_byte) {
103 (Some(left_start), Some(right_start)) => left_start.cmp(&right_start),
104 (Some(_), None) => std::cmp::Ordering::Less,
105 (None, Some(_)) => std::cmp::Ordering::Greater,
106 (None, None) => std::cmp::Ordering::Equal,
107 }
108 .then_with(|| left.end_byte.cmp(&right.end_byte))
109 .then_with(|| left.file_path.cmp(right.file_path))
110 .then_with(|| left.start_line.cmp(&right.start_line))
111 .then_with(|| left.end_line.cmp(&right.end_line))
112 });
113 }
114
115 child_ranges_by_parent
116}
117
118fn child_content_span_in_parent(
119 parent: &SemanticEntity,
120 child: &SemanticEntity,
121 parent_line_starts: &[usize],
122) -> Option<(usize, usize)> {
123 if parent.file_path != child.file_path || child.content.is_empty() {
124 return None;
125 }
126
127 let expected_local_line = child.start_line.checked_sub(parent.start_line)? + 1;
128 if let Some(span) = child_content_span_at_expected_line(
129 &parent.content,
130 &child.content,
131 expected_local_line,
132 parent_line_starts,
133 ) {
134 return Some(span);
135 }
136
137 for (offset, _) in parent.content.match_indices(&child.content) {
138 let local_line = line_for_byte(&parent.content, offset);
139 if local_line == expected_local_line {
140 return Some((offset, offset + child.content.len()));
141 }
142 }
143
144 None
145}
146
147fn child_content_span_at_expected_line(
148 parent_content: &str,
149 child_content: &str,
150 expected_local_line: usize,
151 parent_line_starts: &[usize],
152) -> Option<(usize, usize)> {
153 let line_start = *parent_line_starts.get(expected_local_line.checked_sub(1)?)?;
154 if let Some(span) = content_span_at(parent_content, child_content, line_start) {
155 return Some(span);
156 }
157
158 let line_end = parent_line_starts
159 .get(expected_local_line)
160 .copied()
161 .map(|next_line_start| next_line_start.saturating_sub(1))
162 .unwrap_or(parent_content.len());
163 let line = parent_content.get(line_start..line_end)?;
164
165 let trimmed_line_start = line_start + line.len().saturating_sub(line.trim_start().len());
166 if trimmed_line_start != line_start {
167 if let Some(span) = content_span_at(parent_content, child_content, trimmed_line_start) {
168 return Some(span);
169 }
170 }
171
172 let first_child_line = child_content
173 .split_once('\n')
174 .map_or(child_content, |(line, _)| line);
175 if first_child_line.is_empty() {
176 return None;
177 }
178
179 for (candidate_offset, _) in line.match_indices(first_child_line) {
180 if let Some(span) =
181 content_span_at(parent_content, child_content, line_start + candidate_offset)
182 {
183 return Some(span);
184 }
185 }
186
187 None
188}
189
190fn content_span_at(content: &str, needle: &str, start: usize) -> Option<(usize, usize)> {
191 let end = start.checked_add(needle.len())?;
192 (content.get(start..end) == Some(needle)).then_some((start, end))
193}
194
195fn entity_owns_content_span(
196 entity_id: &str,
197 file_path: &str,
198 source_line: usize,
199 local_start_byte: Option<usize>,
200 local_end_byte: Option<usize>,
201 child_ranges_by_parent: &HashMap<&str, Vec<ChildRange<'_>>>,
202) -> bool {
203 let Some(child_ranges) = child_ranges_by_parent.get(entity_id) else {
204 return true;
205 };
206
207 let child_has_source_line = |child: &ChildRange<'_>| {
208 child.file_path == file_path
209 && source_line >= child.start_line
210 && source_line <= child.end_line
211 };
212
213 let first_without_byte = child_ranges.partition_point(|child| child.start_byte.is_some());
214 if let (Some(start), Some(end)) = (local_start_byte, local_end_byte) {
215 let byte_ranges = &child_ranges[..first_without_byte];
216 let possible_end = byte_ranges.partition_point(|child| {
217 child
218 .start_byte
219 .is_some_and(|child_start| child_start < end)
220 });
221 for child in byte_ranges[..possible_end].iter().rev() {
222 let (Some(child_start), Some(child_end)) = (child.start_byte, child.end_byte) else {
223 continue;
224 };
225 if child_end <= start {
226 break;
227 }
228 if start < child_end && end > child_start && child_has_source_line(child) {
229 return false;
230 }
231 }
232 } else if child_ranges.iter().any(child_has_source_line) {
233 return false;
234 }
235
236 !child_ranges[first_without_byte..]
237 .iter()
238 .any(child_has_source_line)
239}
240
241fn source_line_for_entity_content(entity: &SemanticEntity, local_line: usize) -> usize {
242 entity.start_line + local_line.saturating_sub(1)
243}
244
245fn entity_requires_content_span_filter(
246 entity: &SemanticEntity,
247 child_ranges_by_parent: &HashMap<&str, Vec<ChildRange<'_>>>,
248) -> bool {
249 entity.start_line == entity.end_line
250 || child_ranges_by_parent
251 .get(entity.id.as_str())
252 .map_or(false, |children| {
253 children.iter().any(|child| {
254 child.start_line == child.end_line
255 || child.start_line == entity.start_line
256 || child.end_line == entity.end_line
257 })
258 })
259}
260
261fn line_for_byte(content: &str, byte: usize) -> usize {
262 1 + content[..byte]
263 .bytes()
264 .filter(|current| *current == b'\n')
265 .count()
266}
267
268fn line_start_offsets(content: &str) -> Vec<usize> {
269 let mut starts = vec![0];
270 for (idx, byte) in content.bytes().enumerate() {
271 if byte == b'\n' {
272 starts.push(idx + 1);
273 }
274 }
275 starts
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct EntityRef {
282 pub from_entity: String,
283 pub to_entity: String,
284 pub ref_type: RefType,
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289#[serde(rename_all = "lowercase")]
290pub enum RefType {
291 Calls,
293 TypeRef,
295 Imports,
297}
298
299#[derive(Debug)]
301pub struct EntityGraph {
302 pub entities: EntityInfoMap,
304 pub edges: Vec<EntityRef>,
306 pub dependents: EntityAdjacencyMap,
308 pub dependencies: EntityAdjacencyMap,
310}
311
312#[derive(Debug, Clone, Default, PartialEq, Eq)]
314pub struct IncrementalBuildMetadata {
315 pub repaired_clean_entity_ids: bool,
316 pub recomputed_edge_source_ids: Vec<String>,
317 pub deleted_entity_ids: Vec<String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct EntityInfo {
324 pub id: String,
325 pub name: String,
326 pub entity_type: String,
327 pub file_path: String,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub parent_id: Option<String>,
330 pub start_line: usize,
331 pub end_line: usize,
332}
333
334pub type EntityInfoMap = StdHashMap<String, EntityInfo>;
335pub type EntityAdjacencyMap = StdHashMap<String, Vec<String>>;
336
337fn sort_symbol_table_targets_by_source(
338 symbol_table: &mut HashMap<String, Vec<String>>,
339 entity_map: &HashMap<String, EntityInfo>,
340) {
341 for target_ids in symbol_table.values_mut() {
342 if target_ids.len() > 1 {
343 target_ids.sort_unstable_by(|left, right| {
344 match (entity_map.get(left), entity_map.get(right)) {
345 (Some(left), Some(right)) => (
346 left.file_path.as_str(),
347 left.start_line,
348 left.end_line,
349 left.id.as_str(),
350 )
351 .cmp(&(
352 right.file_path.as_str(),
353 right.start_line,
354 right.end_line,
355 right.id.as_str(),
356 )),
357 (Some(_), None) => std::cmp::Ordering::Less,
358 (None, Some(_)) => std::cmp::Ordering::Greater,
359 (None, None) => left.cmp(right),
360 }
361 });
362 }
363 }
364}
365
366fn dedupe_resolved_edges(
367 combined: Vec<(String, String, RefType)>,
368) -> Vec<(String, String, RefType)> {
369 let mut keep = vec![false; combined.len()];
370 let mut seen_edges: HashSet<(&str, &str)> =
371 HashSet::with_capacity_and_hasher(combined.len(), Default::default());
372 for (index, (from_entity, to_entity, _)) in combined.iter().enumerate() {
373 if seen_edges.insert((from_entity.as_str(), to_entity.as_str())) {
374 keep[index] = true;
375 }
376 }
377 drop(seen_edges);
378
379 combined
380 .into_iter()
381 .enumerate()
382 .filter_map(|(index, edge)| keep[index].then_some(edge))
383 .collect()
384}
385
386fn sort_resolved_refs(refs: &mut [(String, String, RefType)]) {
387 refs.sort_by(|left, right| {
388 left.0
389 .cmp(&right.0)
390 .then_with(|| left.1.cmp(&right.1))
391 .then_with(|| ref_type_sort_key(&left.2).cmp(&ref_type_sort_key(&right.2)))
392 });
393}
394
395fn sort_entity_refs(refs: &mut [EntityRef]) {
396 refs.sort_by(|left, right| {
397 left.from_entity
398 .cmp(&right.from_entity)
399 .then_with(|| left.to_entity.cmp(&right.to_entity))
400 .then_with(|| {
401 ref_type_sort_key(&left.ref_type).cmp(&ref_type_sort_key(&right.ref_type))
402 })
403 });
404}
405
406fn ref_type_sort_key(ref_type: &RefType) -> u8 {
407 match ref_type {
408 RefType::Calls => 0,
409 RefType::Imports => 1,
410 RefType::TypeRef => 2,
411 }
412}
413
414#[derive(Debug)]
415struct LineReferenceIndex {
416 words: Vec<IndexedWordRef>,
417 dot_chains: Vec<(u32, u32)>,
418}
419
420#[derive(Debug)]
421struct FileReferenceIndex {
422 tokens: Vec<String>,
423 token_ids: HashMap<String, u32>,
424 lines: Vec<Option<LineReferenceIndex>>,
425}
426
427#[derive(Debug, Clone, Copy)]
428struct IndexedWordRef {
429 token_id: u32,
430 flags: u8,
431}
432
433impl IndexedWordRef {
434 const CALL: u8 = 0b01;
435 const IMPORT: u8 = 0b10;
436}
437
438impl FileReferenceIndex {
439 #[cfg(test)]
440 fn from_content(content: &str, extra_ident_chars: &'static [char]) -> Self {
441 let stripped = strip_comments_and_strings(content);
442 Self::from_stripped(&stripped, extra_ident_chars)
443 }
444
445 fn from_stripped(stripped: &str, extra_ident_chars: &'static [char]) -> Self {
446 let mut index = Self {
447 tokens: Vec::new(),
448 token_ids: HashMap::default(),
449 lines: Vec::new(),
450 };
451 let lines = stripped
452 .lines()
453 .map(|line| LineReferenceIndex::from_stripped_line(line, &mut index, extra_ident_chars))
454 .collect();
455 index.lines = lines;
456 index
457 }
458
459 fn dot_chains_in_ranges(&self, ranges: &[(usize, usize)]) -> Vec<(&str, &str)> {
460 let mut chains = Vec::new();
461 let mut seen: HashSet<(u32, u32)> = HashSet::default();
462 for &(start_line, end_line) in ranges {
463 for line in self.line_range(start_line, end_line) {
464 for (receiver, member) in &line.dot_chains {
465 let pair = (*receiver, *member);
466 if seen.insert(pair) {
467 chains.push((self.token(*receiver), self.token(*member)));
468 }
469 }
470 }
471 }
472 chains
473 }
474
475 fn refs_with_types_in_ranges(
476 &self,
477 ranges: &[(usize, usize)],
478 own_name: &str,
479 ) -> Vec<(&str, RefType)> {
480 let mut refs = Vec::new();
481 let mut seen: HashMap<u32, u8> = HashMap::default();
482 for &(start_line, end_line) in ranges {
483 for line in self.line_range(start_line, end_line) {
484 for word in &line.words {
485 let word_text = self.token(word.token_id);
486 if word_text == own_name {
487 continue;
488 }
489 let first_seen = !seen.contains_key(&word.token_id);
490 let flags = seen.entry(word.token_id).or_insert(0);
491 *flags |= word.flags;
492 if first_seen {
493 refs.push(word.token_id);
494 }
495 }
496 }
497 }
498 refs.into_iter()
499 .map(|token_id| {
500 let flags = seen.get(&token_id).copied().unwrap_or_default();
501 let ref_type = if flags & IndexedWordRef::CALL != 0 {
502 RefType::Calls
503 } else if flags & IndexedWordRef::IMPORT != 0 {
504 RefType::Imports
505 } else {
506 RefType::TypeRef
507 };
508 (self.token(token_id), ref_type)
509 })
510 .collect()
511 }
512
513 fn line_range(
514 &self,
515 start_line: usize,
516 end_line: usize,
517 ) -> impl Iterator<Item = &LineReferenceIndex> {
518 let start = start_line.saturating_sub(1).min(self.lines.len());
519 let end = end_line.min(self.lines.len()).max(start);
520 self.lines[start..end].iter().filter_map(Option::as_ref)
521 }
522
523 fn intern(&mut self, token: &str) -> u32 {
524 if let Some(id) = self.token_ids.get(token) {
525 return *id;
526 }
527 let id = self.tokens.len() as u32;
528 self.tokens.push(token.to_string());
529 self.token_ids.insert(token.to_string(), id);
530 id
531 }
532
533 fn token(&self, token_id: u32) -> &str {
534 self.tokens
535 .get(token_id as usize)
536 .map(String::as_str)
537 .unwrap_or("")
538 }
539}
540
541impl LineReferenceIndex {
542 fn from_stripped_line(
543 line: &str,
544 file_index: &mut FileReferenceIndex,
545 extra_ident_chars: &'static [char],
546 ) -> Option<Self> {
547 let mut words = Vec::new();
548 let mut seen_words: HashSet<u32> = HashSet::default();
549 let import_like = {
550 let trimmed = line.trim();
551 trimmed.starts_with("import ")
552 || trimmed.starts_with("use ")
553 || trimmed.starts_with("from ")
554 || trimmed.starts_with("require(")
555 };
556
557 for (word, end_byte) in identifier_tokens(line, extra_ident_chars) {
558 if !is_reference_word(word) {
559 continue;
560 }
561 let token_id = file_index.intern(word);
562 let mut flags = 0;
563 if line.as_bytes().get(end_byte) == Some(&b'(') {
564 flags |= IndexedWordRef::CALL;
565 }
566 if import_like {
567 flags |= IndexedWordRef::IMPORT;
568 }
569 if seen_words.insert(token_id) {
570 words.push(IndexedWordRef { token_id, flags });
571 } else if let Some(indexed) = words
572 .iter_mut()
573 .find(|indexed| indexed.token_id == token_id)
574 {
575 indexed.flags |= flags;
576 }
577 }
578
579 let dot_chains: Vec<(u32, u32)> = extract_dot_chains(line)
580 .into_iter()
581 .map(|(receiver, member)| (file_index.intern(receiver), file_index.intern(member)))
582 .collect();
583
584 if words.is_empty() && dot_chains.is_empty() {
585 return None;
586 }
587
588 Some(Self { words, dot_chains })
589 }
590}
591
592fn identifier_tokens<'a>(
593 line: &'a str,
594 extra_ident_chars: &'static [char],
595) -> impl Iterator<Item = (&'a str, usize)> {
596 let mut start = None;
597 let mut chars = line.char_indices();
598
599 std::iter::from_fn(move || {
600 for (idx, ch) in chars.by_ref() {
601 if ch.is_alphanumeric() || ch == '_' || extra_ident_chars.contains(&ch) {
602 if start.is_none() {
603 start = Some(idx);
604 }
605 } else if let Some(token_start) = start.take() {
606 return Some((&line[token_start..idx], idx));
607 }
608 }
609
610 start
611 .take()
612 .map(|token_start| (&line[token_start..], line.len()))
613 })
614}
615
616fn is_reference_word(word: &str) -> bool {
617 if word.is_empty() {
618 return false;
619 }
620 if is_keyword(word) || word.len() < 2 {
621 return false;
622 }
623 if word.starts_with(|c: char| c.is_lowercase()) && word.len() < 3 {
624 return false;
625 }
626 if word
633 .chars()
634 .all(|c| !c.is_alphanumeric() && c != '_' && c != '-')
635 {
636 return false;
637 }
638 if !word.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '-' || c == '*') {
643 return false;
644 }
645 if is_common_local_name(word) {
646 return false;
647 }
648 true
649}
650
651fn is_function_like_entity_type(entity_type: &str) -> bool {
652 matches!(
653 entity_type,
654 "function" | "method" | "constructor" | "getter" | "setter"
655 )
656}
657
658fn fallback_reference_end_line(entity: &SemanticEntity, has_scope_resolve: bool) -> usize {
659 if !has_scope_resolve || is_function_like_entity_type(&entity.entity_type) {
660 return entity.end_line;
661 }
662
663 let mut prefix_lines = 0usize;
664 for line in entity.content.lines() {
665 prefix_lines += 1;
666 let trimmed = line.trim_end();
667 if line.contains('{') || trimmed.ends_with(':') || trimmed.ends_with(';') {
668 break;
669 }
670 if prefix_lines >= 16 {
671 break;
672 }
673 }
674
675 (entity.start_line + prefix_lines.saturating_sub(1))
676 .min(entity.end_line)
677 .max(entity.start_line)
678}
679
680fn direct_reference_line_ranges(
681 entity: &SemanticEntity,
682 fallback_end_line: usize,
683 child_line_ranges: &HashMap<String, Vec<(usize, usize)>>,
684) -> Vec<(usize, usize)> {
685 let start_line = entity.start_line;
686 let end_line = fallback_end_line.min(entity.end_line).max(start_line);
687 let mut ranges = Vec::new();
688 let mut next_line = start_line;
689
690 if let Some(children) = child_line_ranges.get(&entity.id) {
691 for &(child_start, child_end) in children {
692 if child_end < next_line {
693 continue;
694 }
695 if child_start > end_line {
696 break;
697 }
698
699 let child_start = child_start.max(start_line);
700 let child_end = child_end.min(end_line);
701 if next_line < child_start {
702 ranges.push((next_line, child_start - 1));
703 }
704 next_line = next_line.max(child_end.saturating_add(1));
705 if next_line > end_line {
706 break;
707 }
708 }
709 }
710
711 if next_line <= end_line {
712 ranges.push((next_line, end_line));
713 }
714
715 ranges
716}
717
718type ImportsByFile<'a> = HashMap<&'a str, HashMap<&'a str, &'a str>>;
719
720fn build_imports_by_file<'a>(
721 import_table: &'a HashMap<(String, String), String>,
722) -> ImportsByFile<'a> {
723 let mut imports_by_file: ImportsByFile<'a> = HashMap::default();
724 for ((file_path, import_name), target_id) in import_table {
725 imports_by_file
726 .entry(file_path.as_str())
727 .or_default()
728 .insert(import_name.as_str(), target_id.as_str());
729 }
730 imports_by_file
731}
732
733struct ReferenceResolutionContext<'a> {
734 symbol_table: &'a HashMap<String, Vec<String>>,
735 entity_map: &'a HashMap<String, EntityInfo>,
736 imports_by_file: &'a ImportsByFile<'a>,
737 scope_consumed_words: &'a HashMap<String, HashSet<String>>,
738 child_ranges_by_parent: &'a HashMap<&'a str, Vec<ChildRange<'a>>>,
739 child_line_ranges: &'a HashMap<String, Vec<(usize, usize)>>,
740 parent_child_pairs: &'a HashSet<(&'a str, &'a str)>,
741 class_child_names: &'a HashSet<(&'a str, &'a str)>,
742 class_entity_files: &'a HashSet<(&'a str, &'a str)>,
743 enclosing_class: &'a HashMap<&'a str, &'a str>,
744 class_members: &'a HashMap<&'a str, Vec<(&'a str, &'a str)>>,
745}
746
747fn resolve_references_with_file_indexes<'a>(
748 root: &Path,
749 file_paths: &[String],
750 all_entities: &'a [SemanticEntity],
751 needs_resolution: Option<&HashSet<&'a str>>,
752 context: &ReferenceResolutionContext<'a>,
753) -> Vec<(String, String, RefType)> {
754 let mut entities_by_file: HashMap<&'a str, Vec<&'a SemanticEntity>> = HashMap::default();
755 for entity in all_entities {
756 if needs_resolution
757 .as_ref()
758 .is_some_and(|ids| !ids.contains(entity.id.as_str()))
759 {
760 continue;
761 }
762 let ext = entity
763 .file_path
764 .rfind('.')
765 .map(|i| &entity.file_path[i..])
766 .unwrap_or("");
767 if crate::parser::plugins::code::languages::get_language_config(ext).is_none() {
768 continue;
769 }
770 entities_by_file
771 .entry(entity.file_path.as_str())
772 .or_default()
773 .push(entity);
774 }
775
776 let mut sorted_file_paths = file_paths.to_vec();
777 sorted_file_paths.sort_unstable();
778 sorted_file_paths.dedup();
779
780 maybe_par_iter!(sorted_file_paths)
781 .filter_map(|file_path| {
782 let entities = entities_by_file.get(file_path.as_str())?;
783 let needs_index = entities.iter().any(|entity| {
784 !entity_requires_content_span_filter(entity, context.child_ranges_by_parent)
785 });
786 let reference_index = if needs_index {
787 build_file_reference_index(root, file_path)
788 } else {
789 None
790 };
791
792 let mut file_edges = Vec::new();
793 for entity in entities {
794 file_edges.extend(resolve_entity_references(
795 entity,
796 reference_index.as_ref(),
797 context,
798 ));
799 }
800 Some(file_edges)
801 })
802 .collect::<Vec<_>>()
803 .into_iter()
804 .flatten()
805 .collect()
806}
807
808fn build_file_reference_index(root: &Path, file_path: &str) -> Option<FileReferenceIndex> {
809 let ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
810 let config = crate::parser::plugins::code::languages::get_language_config(ext)?;
811 let content = std::fs::read_to_string(root.join(file_path)).ok()?;
812 let stripped = strip_for_language(config.strip_strategy(), &content);
813 Some(FileReferenceIndex::from_stripped(
814 &stripped,
815 extra_ident_chars_for_file(file_path),
816 ))
817}
818
819fn resolve_scopes_in_file_chunks(
820 root: &Path,
821 file_paths: &[String],
822 all_entities: &[SemanticEntity],
823 entity_map: &HashMap<String, EntityInfo>,
824 pre_built: &scope_resolve::PreBuiltLookups,
825 import_table: &HashMap<(String, String), String>,
826) -> (
827 Vec<(String, String, RefType)>,
828 HashMap<String, HashSet<String>>,
829) {
830 let mut all_edges = Vec::new();
831 let mut all_consumed_words: HashMap<String, HashSet<String>> = HashMap::default();
832
833 for chunk in file_paths.chunks(SCOPE_RESOLVE_FILE_CHUNK_SIZE) {
834 if !chunk.iter().any(|file_path| {
835 let ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
836 crate::parser::plugins::code::languages::get_language_config(ext)
837 .and_then(|config| config.scope_resolve)
838 .is_some()
839 }) {
840 continue;
841 }
842
843 let result = scope_resolve::resolve_with_scopes_full(
844 root,
845 chunk,
846 all_entities,
847 entity_map,
848 None,
849 Some(pre_built),
850 Some(import_table),
851 false,
852 );
853 all_edges.extend(result.edges);
854 for (entity_id, words) in result.consumed_words {
855 all_consumed_words
856 .entry(entity_id)
857 .or_default()
858 .extend(words);
859 }
860 }
861
862 (all_edges, all_consumed_words)
863}
864
865fn resolve_entity_references(
866 entity: &SemanticEntity,
867 reference_index: Option<&FileReferenceIndex>,
868 context: &ReferenceResolutionContext<'_>,
869) -> Vec<(String, String, RefType)> {
870 let ext = entity
871 .file_path
872 .rfind('.')
873 .map(|i| &entity.file_path[i..])
874 .unwrap_or("");
875 let Some(language_config) = crate::parser::plugins::code::languages::get_language_config(ext)
876 else {
877 return vec![];
878 };
879 let fallback_end_line =
880 fallback_reference_end_line(entity, language_config.scope_resolve.is_some());
881 let fallback_ranges =
882 direct_reference_line_ranges(entity, fallback_end_line, context.child_line_ranges);
883
884 let mut entity_edges = Vec::new();
885
886 let reference_index =
887 if entity_requires_content_span_filter(entity, context.child_ranges_by_parent) {
888 None
889 } else {
890 reference_index
891 };
892 let fallback_stripped = if reference_index.is_none() {
893 Some(strip_for_language(
894 language_config.strip_strategy(),
895 &entity.content,
896 ))
897 } else {
898 None
899 };
900 let local_bindings =
901 local_binding_names_filtered(&entity.content, ext, |local_line, start, end| {
902 entity_owns_content_span(
903 entity.id.as_str(),
904 entity.file_path.as_str(),
905 source_line_for_entity_content(entity, local_line),
906 Some(start),
907 Some(end),
908 context.child_ranges_by_parent,
909 )
910 });
911
912 let dot_chains: Vec<(&str, &str, Option<(usize, usize, usize)>)> = match reference_index {
913 Some(index) => index
914 .dot_chains_in_ranges(&fallback_ranges)
915 .into_iter()
916 .map(|(receiver, member)| (receiver, member, None))
917 .collect(),
918 None => extract_dot_chains_with_positions(fallback_stripped.as_ref().unwrap())
919 .into_iter()
920 .map(|(receiver, member, line, start, end)| {
921 (receiver, member, Some((line, start, end)))
922 })
923 .collect(),
924 };
925 let mut consumed_words: HashSet<&str> = context
926 .scope_consumed_words
927 .get(&entity.id)
928 .map(|set| set.iter().map(String::as_str).collect())
929 .unwrap_or_default();
930
931 for (receiver, member, position) in &dot_chains {
932 if consumed_words.contains(*member) {
933 continue;
934 }
935 if let Some((local_line, local_start_byte, local_end_byte)) = *position {
936 if !entity_owns_content_span(
937 entity.id.as_str(),
938 entity.file_path.as_str(),
939 source_line_for_entity_content(entity, local_line),
940 Some(local_start_byte),
941 Some(local_end_byte),
942 context.child_ranges_by_parent,
943 ) {
944 continue;
945 }
946 }
947 let edge_count_before = entity_edges.len();
948 if *receiver == "self" || *receiver == "this" {
949 if let Some(class_name) = context.enclosing_class.get(entity.id.as_str()) {
950 if let Some(members) = context.class_members.get(class_name) {
951 for (name, target_id) in members {
952 if *name == *member && *target_id != entity.id.as_str() {
953 entity_edges.push((
954 entity.id.clone(),
955 target_id.to_string(),
956 RefType::Calls,
957 ));
958 consumed_words.insert(*member);
959 break;
960 }
961 }
962 }
963 }
964 } else if context
965 .class_entity_files
966 .contains(&(*receiver, entity.file_path.as_str()))
967 {
968 if let Some(members) = context.class_members.get(*receiver) {
969 for (name, target_id) in members {
970 if *name == *member {
971 entity_edges.push((
972 entity.id.clone(),
973 target_id.to_string(),
974 RefType::Calls,
975 ));
976 consumed_words.insert(*member);
977 consumed_words.insert(*receiver);
978 break;
979 }
980 }
981 }
982 }
983 if entity_edges.len() == edge_count_before {
984 consumed_words.insert(*member);
985 }
986 }
987
988 let refs: Vec<(&str, RefType)> = match reference_index {
989 Some(index) => index.refs_with_types_in_ranges(&fallback_ranges, &entity.name),
990 None => {
991 let stripped = fallback_stripped.as_ref().unwrap();
992 extract_references_with_stripped_filtered(
993 &entity.content,
994 &entity.name,
995 stripped,
996 extra_ident_chars_for_file(&entity.file_path),
997 |local_line, local_start_byte, local_end_byte| {
998 entity_owns_content_span(
999 entity.id.as_str(),
1000 entity.file_path.as_str(),
1001 source_line_for_entity_content(entity, local_line),
1002 Some(local_start_byte),
1003 Some(local_end_byte),
1004 context.child_ranges_by_parent,
1005 )
1006 },
1007 )
1008 .into_iter()
1009 .map(|ref_name| (ref_name, infer_ref_type(&entity.content, ref_name)))
1010 .collect()
1011 }
1012 };
1013 let entity_id = entity.id.as_str();
1014 let imports_for_file = context.imports_by_file.get(entity.file_path.as_str());
1015
1016 for (ref_name, ref_type) in refs {
1017 if consumed_words.contains(ref_name) {
1018 continue;
1019 }
1020 if local_bindings.contains(ref_name) {
1021 continue;
1022 }
1023
1024 if context
1025 .class_child_names
1026 .contains(&(entity.id.as_str(), ref_name))
1027 {
1028 continue;
1029 }
1030
1031 if let Some(import_target_id) =
1032 imports_for_file.and_then(|imports| imports.get(ref_name).copied())
1033 {
1034 if import_target_id != entity_id
1035 && !context
1036 .parent_child_pairs
1037 .contains(&(entity_id, import_target_id))
1038 && !context
1039 .parent_child_pairs
1040 .contains(&(import_target_id, entity_id))
1041 {
1042 entity_edges.push((entity.id.clone(), import_target_id.to_string(), ref_type));
1043 }
1044 continue;
1045 }
1046
1047 if let Some(target_ids) = context.symbol_table.get(ref_name) {
1048 let target = target_ids.iter().find(|id| {
1049 *id != &entity.id
1050 && context
1051 .entity_map
1052 .get(*id)
1053 .map_or(false, |e| e.file_path == entity.file_path)
1054 });
1055
1056 if let Some(target_id) = target {
1057 if context
1058 .parent_child_pairs
1059 .contains(&(entity.id.as_str(), target_id.as_str()))
1060 || context
1061 .parent_child_pairs
1062 .contains(&(target_id.as_str(), entity.id.as_str()))
1063 {
1064 continue;
1065 }
1066 entity_edges.push((entity.id.clone(), target_id.clone(), ref_type));
1067 }
1068 }
1069 }
1070
1071 if language_config.has_slash_qualified_refs() {
1076 let qualified_ref_stripped =
1079 strip_for_language(language_config.strip_strategy(), &entity.content);
1080 for cap in CLOJURE_QUALIFIED_REF_RE.captures_iter(&qualified_ref_stripped) {
1081 let qualified = cap.get(1).unwrap().as_str();
1082 if let Some(import_target_id) =
1083 imports_for_file.and_then(|imports| imports.get(qualified).copied())
1084 {
1085 if import_target_id != entity_id
1086 && !context
1087 .parent_child_pairs
1088 .contains(&(entity_id, import_target_id))
1089 && !context
1090 .parent_child_pairs
1091 .contains(&(import_target_id, entity_id))
1092 {
1093 entity_edges.push((
1094 entity.id.clone(),
1095 import_target_id.to_string(),
1096 RefType::Calls,
1097 ));
1098 }
1099 }
1100 }
1101 }
1102
1103 entity_edges
1104}
1105
1106impl EntityGraph {
1107 pub fn from_parts(entities: EntityInfoMap, mut edges: Vec<EntityRef>) -> Self {
1109 sort_entity_refs(&mut edges);
1110 let mut dependents: EntityAdjacencyMap = StdHashMap::new();
1111 let mut dependencies: EntityAdjacencyMap = StdHashMap::new();
1112 for edge in &edges {
1113 dependents
1114 .entry(edge.to_entity.clone())
1115 .or_default()
1116 .push(edge.from_entity.clone());
1117 dependencies
1118 .entry(edge.from_entity.clone())
1119 .or_default()
1120 .push(edge.to_entity.clone());
1121 }
1122 EntityGraph {
1123 entities,
1124 edges,
1125 dependents,
1126 dependencies,
1127 }
1128 }
1129
1130 pub fn build(
1136 root: &Path,
1137 file_paths: &[String],
1138 registry: &ParserRegistry,
1139 ) -> (Self, Vec<SemanticEntity>) {
1140 let retain_parsed_files = file_paths.len() <= PARSED_FILE_REUSE_LIMIT;
1141 let per_file: Vec<(
1145 Vec<SemanticEntity>,
1146 Option<(String, String, tree_sitter::Tree)>,
1147 )> = maybe_par_iter!(file_paths)
1148 .filter_map(|file_path| {
1149 let full_path = root.join(file_path);
1150 let content = std::fs::read_to_string(&full_path).ok()?;
1151 if retain_parsed_files {
1152 let (entities, tree) =
1153 registry.extract_entities_with_tree(file_path, &content)?;
1154 let parsed = tree.map(|tree| (file_path.clone(), content, tree));
1155 Some((entities, parsed))
1156 } else {
1157 let entities = registry.extract_entities(file_path, &content);
1158 Some((entities, None))
1159 }
1160 })
1161 .collect();
1162
1163 let mut all_entities: Vec<SemanticEntity> = Vec::new();
1164 let mut parsed_files: Vec<(String, String, tree_sitter::Tree)> = Vec::new();
1165 for (entities, parsed) in per_file {
1166 all_entities.extend(entities);
1167 if let Some(p) = parsed {
1168 parsed_files.push(p);
1169 }
1170 }
1171 resolve_go_method_parent_ids(&mut all_entities);
1172
1173 let mut symbol_table: HashMap<String, Vec<String>> =
1176 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1177 let mut entity_map: HashMap<String, EntityInfo> =
1178 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1179 let mut parent_child_pairs: HashSet<(&str, &str)> = HashSet::default();
1180 let mut child_line_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::default();
1181 let mut class_child_names: HashSet<(&str, &str)> = HashSet::default();
1182 let child_ranges_by_parent = build_child_ranges_by_parent(&all_entities);
1183 let mut class_entity_names: HashSet<&str> = HashSet::default();
1184 let mut class_entity_files: HashSet<(&str, &str)> = HashSet::default();
1185 let mut id_to_name: HashMap<&str, &str> =
1186 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1187 let mut scope_entity_ranges: HashMap<String, Vec<(usize, usize, String)>> =
1188 HashMap::default();
1189
1190 for entity in &all_entities {
1191 symbol_table
1192 .entry(entity.name.clone())
1193 .or_default()
1194 .push(entity.id.clone());
1195
1196 entity_map.insert(
1197 entity.id.clone(),
1198 EntityInfo {
1199 id: entity.id.clone(),
1200 name: entity.name.clone(),
1201 entity_type: entity.entity_type.clone(),
1202 file_path: entity.file_path.clone(),
1203 parent_id: entity.parent_id.clone(),
1204 start_line: entity.start_line,
1205 end_line: entity.end_line,
1206 },
1207 );
1208
1209 if let Some(ref pid) = entity.parent_id {
1210 parent_child_pairs.insert((pid.as_str(), entity.id.as_str()));
1211 child_line_ranges
1212 .entry(pid.clone())
1213 .or_default()
1214 .push((entity.start_line, entity.end_line));
1215 class_child_names.insert((pid.as_str(), entity.name.as_str()));
1216 }
1217
1218 if is_nominal_member_container(entity.entity_type.as_str()) {
1219 class_entity_names.insert(entity.name.as_str());
1220 class_entity_files.insert((entity.name.as_str(), entity.file_path.as_str()));
1221 }
1222
1223 id_to_name.insert(entity.id.as_str(), entity.name.as_str());
1224
1225 scope_entity_ranges
1226 .entry(entity.file_path.clone())
1227 .or_default()
1228 .push((entity.start_line, entity.end_line, entity.id.clone()));
1229 }
1230 for ranges in child_line_ranges.values_mut() {
1231 ranges.sort_unstable_by_key(|(start, end)| (*start, *end));
1232 }
1233
1234 let mut enclosing_class: HashMap<&str, &str> = HashMap::default();
1237 let mut class_members: HashMap<&str, Vec<(&str, &str)>> = HashMap::default();
1238 let mut scope_class_members: HashMap<String, Vec<(String, String)>> = HashMap::default();
1239 let mut scope_owner_members: HashMap<String, Vec<(String, String)>> = HashMap::default();
1240
1241 for entity in &all_entities {
1242 if let Some(ref pid) = entity.parent_id {
1243 scope_owner_members
1244 .entry(pid.clone())
1245 .or_default()
1246 .push((entity.name.clone(), entity.id.clone()));
1247 if let Some(&parent_name) = id_to_name.get(pid.as_str()) {
1248 if class_entity_names.contains(parent_name) {
1249 enclosing_class.insert(entity.id.as_str(), parent_name);
1250 class_members
1251 .entry(parent_name)
1252 .or_default()
1253 .push((entity.name.as_str(), entity.id.as_str()));
1254 }
1255 }
1256 if let Some(parent) = entity_map.get(pid.as_str()) {
1258 if let Some(owner_name) = scope_resolve::class_member_owner_name(parent) {
1259 scope_class_members
1260 .entry(owner_name.to_string())
1261 .or_default()
1262 .push((entity.name.clone(), entity.id.clone()));
1263 }
1264 }
1265 }
1266 if entity.entity_type == "method" && entity.file_path.ends_with(".go") {
1268 if let Some(struct_name) = scope_resolve::extract_go_receiver_type(&entity.content)
1269 {
1270 scope_class_members
1271 .entry(struct_name)
1272 .or_default()
1273 .push((entity.name.clone(), entity.id.clone()));
1274 }
1275 }
1276 }
1277 sort_symbol_table_targets_by_source(&mut symbol_table, &entity_map);
1278 let symbol_table = Arc::new(symbol_table);
1279
1280 let import_table = build_import_table(
1283 root,
1284 file_paths,
1285 &symbol_table,
1286 &entity_map,
1287 retain_parsed_files.then_some(parsed_files.as_slice()),
1288 );
1289 let owned_go_pkg_index: HashMap<String, Vec<(String, String)>> =
1291 if file_paths.iter().any(|f| f.ends_with(".go")) {
1292 let mut idx: HashMap<String, Vec<(String, String)>> = HashMap::default();
1293 for (name, target_ids) in symbol_table.iter() {
1294 for target_id in target_ids {
1295 if let Some(entity) = entity_map.get(target_id) {
1296 let file_stem = entity
1297 .file_path
1298 .rsplit('/')
1299 .next()
1300 .unwrap_or(&entity.file_path);
1301 let file_stem = strip_file_ext(file_stem);
1302 idx.entry(file_stem.to_string())
1303 .or_default()
1304 .push((name.clone(), target_id.clone()));
1305 if let Some(parent_start) = entity.file_path.rfind('/') {
1306 let parent_path = &entity.file_path[..parent_start];
1307 if let Some(dir_name_start) = parent_path.rfind('/') {
1308 let dir_name = &parent_path[dir_name_start + 1..];
1309 if dir_name != file_stem {
1310 idx.entry(dir_name.to_string())
1311 .or_default()
1312 .push((name.clone(), target_id.clone()));
1313 }
1314 } else if !parent_path.is_empty() && parent_path != file_stem {
1315 idx.entry(parent_path.to_string())
1316 .or_default()
1317 .push((name.clone(), target_id.clone()));
1318 }
1319 }
1320 }
1321 }
1322 }
1323 for entries in idx.values_mut() {
1324 entries.sort_unstable();
1325 }
1326 idx
1327 } else {
1328 HashMap::default()
1329 };
1330
1331 let pre_built = scope_resolve::PreBuiltLookups {
1332 symbol_table: Arc::clone(&symbol_table),
1333 class_members: scope_class_members,
1334 owner_members: scope_owner_members,
1335 entity_ranges: scope_entity_ranges,
1336 go_pkg_index: owned_go_pkg_index,
1337 };
1338
1339 let has_scope_lang = file_paths.iter().any(|f| {
1341 let ext = f.rfind('.').map(|i| &f[i..]).unwrap_or("");
1342 crate::parser::plugins::code::languages::get_language_config(ext)
1343 .and_then(|c| c.scope_resolve)
1344 .is_some()
1345 });
1346 let (scope_edges, scope_consumed_words) = if has_scope_lang && retain_parsed_files {
1347 let result = scope_resolve::resolve_with_scopes_full(
1348 root,
1349 file_paths,
1350 &all_entities,
1351 &entity_map,
1352 Some(parsed_files),
1353 Some(&pre_built),
1354 Some(&import_table),
1355 false,
1356 );
1357 (result.edges, result.consumed_words)
1358 } else if has_scope_lang {
1359 resolve_scopes_in_file_chunks(
1360 root,
1361 file_paths,
1362 &all_entities,
1363 &entity_map,
1364 &pre_built,
1365 &import_table,
1366 )
1367 } else {
1368 (vec![], HashMap::default())
1369 };
1370
1371 let imports_by_file = build_imports_by_file(&import_table);
1372 let reference_context = ReferenceResolutionContext {
1373 symbol_table: symbol_table.as_ref(),
1374 entity_map: &entity_map,
1375 imports_by_file: &imports_by_file,
1376 scope_consumed_words: &scope_consumed_words,
1377 child_ranges_by_parent: &child_ranges_by_parent,
1378 child_line_ranges: &child_line_ranges,
1379 parent_child_pairs: &parent_child_pairs,
1380 class_child_names: &class_child_names,
1381 class_entity_files: &class_entity_files,
1382 enclosing_class: &enclosing_class,
1383 class_members: &class_members,
1384 };
1385 let resolved_refs = resolve_references_with_file_indexes(
1386 root,
1387 file_paths,
1388 &all_entities,
1389 None,
1390 &reference_context,
1391 );
1392
1393 let export_edges = build_export_alias_edges(&all_entities, &import_table);
1394
1395 let mut combined: Vec<(String, String, RefType)> = scope_edges;
1397 combined.extend(export_edges);
1398 combined.extend(resolved_refs);
1399 let mut all_resolved = dedupe_resolved_edges(combined);
1400 sort_resolved_refs(&mut all_resolved);
1401
1402 let mut edges: Vec<EntityRef> = Vec::with_capacity(all_resolved.len());
1404 let mut dependents: HashMap<String, Vec<String>> = HashMap::default();
1405 let mut dependencies: HashMap<String, Vec<String>> = HashMap::default();
1406
1407 for (from_entity, to_entity, ref_type) in all_resolved {
1408 dependents
1409 .entry(to_entity.clone())
1410 .or_default()
1411 .push(from_entity.clone());
1412 dependencies
1413 .entry(from_entity.clone())
1414 .or_default()
1415 .push(to_entity.clone());
1416 edges.push(EntityRef {
1417 from_entity,
1418 to_entity,
1419 ref_type,
1420 });
1421 }
1422
1423 let graph = EntityGraph {
1424 entities: entity_map.into_iter().collect(),
1425 edges,
1426 dependents: dependents.into_iter().collect(),
1427 dependencies: dependencies.into_iter().collect(),
1428 };
1429
1430 (graph, all_entities)
1431 }
1432
1433 pub fn build_direct_dependencies<F>(
1439 root: &Path,
1440 file_paths: &[String],
1441 registry: &ParserRegistry,
1442 mut should_resolve: F,
1443 ) -> (Self, Vec<SemanticEntity>)
1444 where
1445 F: FnMut(&EntityInfo) -> bool,
1446 {
1447 let retain_parsed_files = file_paths.len() <= PARSED_FILE_REUSE_LIMIT;
1448 let per_file: Vec<(
1449 Vec<SemanticEntity>,
1450 Option<(String, String, tree_sitter::Tree)>,
1451 )> = maybe_par_iter!(file_paths)
1452 .filter_map(|file_path| {
1453 let content = std::fs::read_to_string(root.join(file_path)).ok()?;
1454 if retain_parsed_files {
1455 let (entities, tree) =
1456 registry.extract_entities_with_tree(file_path, &content)?;
1457 let parsed = tree.map(|tree| (file_path.clone(), content, tree));
1458 Some((entities, parsed))
1459 } else {
1460 Some((registry.extract_entities(file_path, &content), None))
1461 }
1462 })
1463 .collect();
1464
1465 let mut all_entities: Vec<SemanticEntity> = Vec::new();
1466 let mut retained_parsed_files: Vec<(String, String, tree_sitter::Tree)> = Vec::new();
1467 for (entities, parsed) in per_file {
1468 all_entities.extend(entities);
1469 if let Some(parsed) = parsed {
1470 retained_parsed_files.push(parsed);
1471 }
1472 }
1473 resolve_go_method_parent_ids(&mut all_entities);
1474
1475 let mut symbol_table: HashMap<String, Vec<String>> =
1476 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1477 let mut entity_map: HashMap<String, EntityInfo> =
1478 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1479 let mut parent_child_pairs: HashSet<(&str, &str)> = HashSet::default();
1480 let mut child_line_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::default();
1481 let mut class_child_names: HashSet<(&str, &str)> = HashSet::default();
1482 let child_ranges_by_parent = build_child_ranges_by_parent(&all_entities);
1483 let mut class_entity_names: HashSet<&str> = HashSet::default();
1484 let mut class_entity_files: HashSet<(&str, &str)> = HashSet::default();
1485 let mut id_to_name: HashMap<&str, &str> =
1486 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1487 let mut scope_entity_ranges: HashMap<String, Vec<(usize, usize, String)>> =
1488 HashMap::default();
1489
1490 for entity in &all_entities {
1491 symbol_table
1492 .entry(entity.name.clone())
1493 .or_default()
1494 .push(entity.id.clone());
1495
1496 entity_map.insert(
1497 entity.id.clone(),
1498 EntityInfo {
1499 id: entity.id.clone(),
1500 name: entity.name.clone(),
1501 entity_type: entity.entity_type.clone(),
1502 file_path: entity.file_path.clone(),
1503 parent_id: entity.parent_id.clone(),
1504 start_line: entity.start_line,
1505 end_line: entity.end_line,
1506 },
1507 );
1508
1509 if let Some(ref pid) = entity.parent_id {
1510 parent_child_pairs.insert((pid.as_str(), entity.id.as_str()));
1511 child_line_ranges
1512 .entry(pid.clone())
1513 .or_default()
1514 .push((entity.start_line, entity.end_line));
1515 class_child_names.insert((pid.as_str(), entity.name.as_str()));
1516 }
1517
1518 if is_nominal_member_container(entity.entity_type.as_str()) {
1519 class_entity_names.insert(entity.name.as_str());
1520 class_entity_files.insert((entity.name.as_str(), entity.file_path.as_str()));
1521 }
1522
1523 id_to_name.insert(entity.id.as_str(), entity.name.as_str());
1524
1525 scope_entity_ranges
1526 .entry(entity.file_path.clone())
1527 .or_default()
1528 .push((entity.start_line, entity.end_line, entity.id.clone()));
1529 }
1530 for ranges in child_line_ranges.values_mut() {
1531 ranges.sort_unstable_by_key(|(start, end)| (*start, *end));
1532 }
1533
1534 let mut enclosing_class: HashMap<&str, &str> = HashMap::default();
1535 let mut class_members: HashMap<&str, Vec<(&str, &str)>> = HashMap::default();
1536 let mut scope_class_members: HashMap<String, Vec<(String, String)>> = HashMap::default();
1537 let mut scope_owner_members: HashMap<String, Vec<(String, String)>> = HashMap::default();
1538
1539 for entity in &all_entities {
1540 if let Some(ref pid) = entity.parent_id {
1541 scope_owner_members
1542 .entry(pid.clone())
1543 .or_default()
1544 .push((entity.name.clone(), entity.id.clone()));
1545 if let Some(&parent_name) = id_to_name.get(pid.as_str()) {
1546 if class_entity_names.contains(parent_name) {
1547 enclosing_class.insert(entity.id.as_str(), parent_name);
1548 class_members
1549 .entry(parent_name)
1550 .or_default()
1551 .push((entity.name.as_str(), entity.id.as_str()));
1552 }
1553 }
1554 if let Some(parent) = entity_map.get(pid.as_str()) {
1555 if let Some(owner_name) = scope_resolve::class_member_owner_name(parent) {
1556 scope_class_members
1557 .entry(owner_name.to_string())
1558 .or_default()
1559 .push((entity.name.clone(), entity.id.clone()));
1560 }
1561 }
1562 }
1563 if entity.entity_type == "method" && entity.file_path.ends_with(".go") {
1564 if let Some(struct_name) = scope_resolve::extract_go_receiver_type(&entity.content)
1565 {
1566 scope_class_members
1567 .entry(struct_name)
1568 .or_default()
1569 .push((entity.name.clone(), entity.id.clone()));
1570 }
1571 }
1572 }
1573 sort_symbol_table_targets_by_source(&mut symbol_table, &entity_map);
1574 let symbol_table = Arc::new(symbol_table);
1575
1576 let mut needs_resolution: HashSet<String> = HashSet::default();
1577 let mut resolve_file_paths: Vec<String> = Vec::new();
1578 let mut resolve_file_set: HashSet<String> = HashSet::default();
1579 let mut entity_ids: Vec<&String> = entity_map.keys().collect();
1580 entity_ids.sort_unstable();
1581 for entity_id in entity_ids {
1582 let Some(entity) = entity_map.get(entity_id) else {
1583 continue;
1584 };
1585 if should_resolve(entity) {
1586 needs_resolution.insert(entity.id.clone());
1587 if resolve_file_set.insert(entity.file_path.clone()) {
1588 resolve_file_paths.push(entity.file_path.clone());
1589 }
1590 }
1591 }
1592 resolve_file_paths.sort_unstable();
1593
1594 if needs_resolution.is_empty() {
1595 return (
1596 EntityGraph {
1597 entities: entity_map.into_iter().collect(),
1598 edges: Vec::new(),
1599 dependents: StdHashMap::new(),
1600 dependencies: StdHashMap::new(),
1601 },
1602 all_entities,
1603 );
1604 }
1605
1606 let scope_file_paths = if file_paths.len() > PARSED_FILE_REUSE_LIMIT {
1607 let mut scoped = Vec::new();
1608 for chunk in file_paths.chunks(SCOPE_RESOLVE_FILE_CHUNK_SIZE) {
1609 if chunk.iter().any(|file| resolve_file_set.contains(file)) {
1610 scoped.extend(chunk.iter().cloned());
1611 }
1612 }
1613 scoped
1614 } else {
1615 file_paths.to_vec()
1616 };
1617 let has_scope_lang = resolve_file_paths.iter().any(|f| {
1618 let ext = f.rfind('.').map(|i| &f[i..]).unwrap_or("");
1619 crate::parser::plugins::code::languages::get_language_config(ext)
1620 .and_then(|c| c.scope_resolve)
1621 .is_some()
1622 });
1623 let parsed_files: Vec<(String, String, tree_sitter::Tree)> = if !has_scope_lang {
1624 Vec::new()
1625 } else if !retained_parsed_files.is_empty() && scope_file_paths.len() == file_paths.len() {
1626 retained_parsed_files
1627 } else {
1628 maybe_par_iter!(&scope_file_paths)
1629 .filter_map(|file_path| {
1630 let content = std::fs::read_to_string(root.join(file_path)).ok()?;
1631 let (_entities, tree) =
1632 registry.extract_entities_with_tree(file_path, &content)?;
1633 tree.map(|tree| (file_path.clone(), content, tree))
1634 })
1635 .collect()
1636 };
1637
1638 let import_table = build_import_table_with_default_export_paths(
1639 root,
1640 &resolve_file_paths,
1641 file_paths,
1642 &symbol_table,
1643 &entity_map,
1644 Some(parsed_files.as_slice()),
1645 );
1646
1647 let owned_go_pkg_index: HashMap<String, Vec<(String, String)>> =
1648 if resolve_file_paths.iter().any(|f| f.ends_with(".go")) {
1649 scope_resolve::build_go_pkg_index(&symbol_table, &entity_map)
1650 } else {
1651 HashMap::default()
1652 };
1653
1654 let pre_built = scope_resolve::PreBuiltLookups {
1655 symbol_table: Arc::clone(&symbol_table),
1656 class_members: scope_class_members,
1657 owner_members: scope_owner_members,
1658 entity_ranges: scope_entity_ranges,
1659 go_pkg_index: owned_go_pkg_index,
1660 };
1661
1662 let needs_resolution_refs: HashSet<&str> =
1663 needs_resolution.iter().map(String::as_str).collect();
1664 let (scope_edges, scope_consumed_words) = if has_scope_lang {
1665 let result = scope_resolve::resolve_with_scopes_full_for_entities(
1666 root,
1667 &scope_file_paths,
1668 &all_entities,
1669 &entity_map,
1670 (!parsed_files.is_empty()).then_some(parsed_files),
1671 Some(&pre_built),
1672 Some(&import_table),
1673 &needs_resolution_refs,
1674 );
1675 (result.edges, result.consumed_words)
1676 } else {
1677 (vec![], HashMap::default())
1678 };
1679
1680 let imports_by_file = build_imports_by_file(&import_table);
1681 let reference_context = ReferenceResolutionContext {
1682 symbol_table: symbol_table.as_ref(),
1683 entity_map: &entity_map,
1684 imports_by_file: &imports_by_file,
1685 scope_consumed_words: &scope_consumed_words,
1686 child_ranges_by_parent: &child_ranges_by_parent,
1687 child_line_ranges: &child_line_ranges,
1688 parent_child_pairs: &parent_child_pairs,
1689 class_child_names: &class_child_names,
1690 class_entity_files: &class_entity_files,
1691 enclosing_class: &enclosing_class,
1692 class_members: &class_members,
1693 };
1694 let resolved_refs = resolve_references_with_file_indexes(
1695 root,
1696 &resolve_file_paths,
1697 &all_entities,
1698 Some(&needs_resolution_refs),
1699 &reference_context,
1700 );
1701
1702 let export_edges = build_export_alias_edges(&all_entities, &import_table)
1703 .into_iter()
1704 .filter(|(from_entity, _, _)| needs_resolution.contains(from_entity))
1705 .collect::<Vec<_>>();
1706
1707 let mut combined: Vec<(String, String, RefType)> = scope_edges
1708 .into_iter()
1709 .filter(|(from_entity, _, _)| needs_resolution.contains(from_entity))
1710 .collect();
1711 combined.extend(export_edges);
1712 combined.extend(resolved_refs);
1713 let mut all_resolved = dedupe_resolved_edges(combined);
1714 sort_resolved_refs(&mut all_resolved);
1715
1716 let mut edges: Vec<EntityRef> = Vec::with_capacity(all_resolved.len());
1717 let mut dependents: HashMap<String, Vec<String>> = HashMap::default();
1718 let mut dependencies: HashMap<String, Vec<String>> = HashMap::default();
1719
1720 for (from_entity, to_entity, ref_type) in all_resolved {
1721 dependents
1722 .entry(to_entity.clone())
1723 .or_default()
1724 .push(from_entity.clone());
1725 dependencies
1726 .entry(from_entity.clone())
1727 .or_default()
1728 .push(to_entity.clone());
1729 edges.push(EntityRef {
1730 from_entity,
1731 to_entity,
1732 ref_type,
1733 });
1734 }
1735
1736 (
1737 EntityGraph {
1738 entities: entity_map.into_iter().collect(),
1739 edges,
1740 dependents: dependents.into_iter().collect(),
1741 dependencies: dependencies.into_iter().collect(),
1742 },
1743 all_entities,
1744 )
1745 }
1746
1747 pub fn build_incremental(
1753 root: &Path,
1754 stale_files: &[String],
1755 all_file_paths: &[String],
1756 cached_entities: Vec<SemanticEntity>,
1757 cached_edges: Vec<EntityRef>,
1758 stale_file_cached_entities: Vec<SemanticEntity>,
1759 registry: &ParserRegistry,
1760 ) -> (Self, Vec<SemanticEntity>) {
1761 let (graph, entities, _) = Self::build_incremental_with_metadata(
1762 root,
1763 stale_files,
1764 all_file_paths,
1765 cached_entities,
1766 cached_edges,
1767 stale_file_cached_entities,
1768 registry,
1769 );
1770 (graph, entities)
1771 }
1772
1773 pub fn build_incremental_with_metadata(
1774 root: &Path,
1775 stale_files: &[String],
1776 all_file_paths: &[String],
1777 cached_entities: Vec<SemanticEntity>,
1778 cached_edges: Vec<EntityRef>,
1779 stale_file_cached_entities: Vec<SemanticEntity>,
1780 registry: &ParserRegistry,
1781 ) -> (Self, Vec<SemanticEntity>, IncrementalBuildMetadata) {
1782 Self::build_incremental_with_metadata_and_import_candidates(
1783 root,
1784 stale_files,
1785 all_file_paths,
1786 cached_entities,
1787 cached_edges,
1788 stale_file_cached_entities,
1789 None,
1790 registry,
1791 )
1792 }
1793
1794 pub fn build_incremental_with_metadata_and_import_candidates(
1795 root: &Path,
1796 stale_files: &[String],
1797 all_file_paths: &[String],
1798 cached_entities: Vec<SemanticEntity>,
1799 cached_edges: Vec<EntityRef>,
1800 stale_file_cached_entities: Vec<SemanticEntity>,
1801 cached_importing_stale_files: Option<&[String]>,
1802 registry: &ParserRegistry,
1803 ) -> (Self, Vec<SemanticEntity>, IncrementalBuildMetadata) {
1804 let stale_set: HashSet<&str> = stale_files.iter().map(|s| s.as_str()).collect();
1806
1807 let per_file: Vec<(
1809 Vec<SemanticEntity>,
1810 Option<(String, String, tree_sitter::Tree)>,
1811 )> = maybe_par_iter!(stale_files)
1812 .filter_map(|file_path| {
1813 let full_path = root.join(file_path);
1814 let content = std::fs::read_to_string(&full_path).ok()?;
1815 let (entities, tree) = registry.extract_entities_with_tree(file_path, &content)?;
1816 let parsed = tree.map(|t| (file_path.clone(), content, t));
1817 Some((entities, parsed))
1818 })
1819 .collect();
1820
1821 let mut new_entities: Vec<SemanticEntity> = Vec::new();
1822 let mut parsed_files: Vec<(String, String, tree_sitter::Tree)> = Vec::new();
1823 for (entities, parsed) in per_file {
1824 new_entities.extend(entities);
1825 if let Some(p) = parsed {
1826 parsed_files.push(p);
1827 }
1828 }
1829
1830 let mut all_entities: Vec<SemanticEntity> = cached_entities
1833 .into_iter()
1834 .chain(new_entities.into_iter())
1835 .collect();
1836 let entity_ids_before_parent_repair: HashSet<String> =
1837 all_entities.iter().map(|e| e.id.clone()).collect();
1838 resolve_go_method_parent_ids(&mut all_entities);
1839 let parent_repaired_ids: HashSet<&str> = all_entities
1840 .iter()
1841 .filter(|e| !entity_ids_before_parent_repair.contains(&e.id))
1842 .map(|e| e.id.as_str())
1843 .collect();
1844 let repaired_clean_entity_ids = all_entities.iter().any(|e| {
1845 parent_repaired_ids.contains(e.id.as_str()) && !stale_set.contains(e.file_path.as_str())
1846 });
1847
1848 let stale_cached_entity_ids: HashSet<&str> = stale_file_cached_entities
1850 .iter()
1851 .map(|e| e.id.as_str())
1852 .collect();
1853
1854 let cached_hashes: HashMap<&str, &str> = stale_file_cached_entities
1856 .iter()
1857 .map(|e| (e.id.as_str(), e.content_hash.as_str()))
1858 .collect();
1859
1860 let mut truly_changed_ids: HashSet<String> = HashSet::default();
1862 let mut content_clean_ids: HashSet<String> = HashSet::default();
1863 for entity in all_entities
1864 .iter()
1865 .filter(|e| stale_set.contains(e.file_path.as_str()))
1866 {
1867 match cached_hashes.get(entity.id.as_str()) {
1868 Some(old_hash) if *old_hash == entity.content_hash.as_str() => {
1869 content_clean_ids.insert(entity.id.clone());
1870 }
1871 _ => {
1872 truly_changed_ids.insert(entity.id.clone());
1874 }
1875 }
1876 }
1877
1878 let new_entity_ids: HashSet<&str> = all_entities
1880 .iter()
1881 .filter(|e| stale_set.contains(e.file_path.as_str()))
1882 .map(|e| e.id.as_str())
1883 .collect();
1884 let deleted_ids: HashSet<&str> = stale_file_cached_entities
1885 .iter()
1886 .filter(|e| !new_entity_ids.contains(e.id.as_str()))
1887 .map(|e| e.id.as_str())
1888 .collect();
1889
1890 let mut symbol_table: HashMap<String, Vec<String>> =
1891 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1892 let mut entity_map: HashMap<String, EntityInfo> =
1893 HashMap::with_capacity_and_hasher(all_entities.len(), Default::default());
1894
1895 for entity in &all_entities {
1896 symbol_table
1897 .entry(entity.name.clone())
1898 .or_default()
1899 .push(entity.id.clone());
1900 entity_map.insert(
1901 entity.id.clone(),
1902 EntityInfo {
1903 id: entity.id.clone(),
1904 name: entity.name.clone(),
1905 entity_type: entity.entity_type.clone(),
1906 file_path: entity.file_path.clone(),
1907 parent_id: entity.parent_id.clone(),
1908 start_line: entity.start_line,
1909 end_line: entity.end_line,
1910 },
1911 );
1912 }
1913 sort_symbol_table_targets_by_source(&mut symbol_table, &entity_map);
1914 let symbol_table = Arc::new(symbol_table);
1915
1916 let entity_file_paths: HashMap<&str, &str> = all_entities
1917 .iter()
1918 .map(|e| (e.id.as_str(), e.file_path.as_str()))
1919 .collect();
1920 let stale_entity_ids: HashSet<&str> = all_entities
1921 .iter()
1922 .filter(|e| stale_set.contains(e.file_path.as_str()))
1923 .map(|e| e.id.as_str())
1924 .collect();
1925 let current_entity_ids: HashSet<&str> =
1926 all_entities.iter().map(|e| e.id.as_str()).collect();
1927 let mut stale_or_cached_stale_entity_ids: HashSet<&str> = HashSet::with_capacity_and_hasher(
1928 stale_entity_ids.len() + stale_cached_entity_ids.len(),
1929 Default::default(),
1930 );
1931 stale_or_cached_stale_entity_ids.extend(stale_entity_ids.iter().copied());
1932 stale_or_cached_stale_entity_ids.extend(stale_cached_entity_ids.iter().copied());
1933
1934 let has_new_or_deleted_stale_entities = all_entities.iter().any(|entity| {
1935 stale_set.contains(entity.file_path.as_str())
1936 && !cached_hashes.contains_key(entity.id.as_str())
1937 }) || !deleted_ids.is_empty();
1938
1939 let mut affected_clean_ids: HashSet<String> = HashSet::default();
1941 let mut affected_clean_file_paths: HashSet<&str> = HashSet::default();
1942 for edge in &cached_edges {
1943 let to_truly_changed = truly_changed_ids.contains(&edge.to_entity)
1944 || deleted_ids.contains(edge.to_entity.as_str());
1945 let to_stale_file = stale_or_cached_stale_entity_ids.contains(edge.to_entity.as_str());
1946 let from_file_path = entity_file_paths.get(edge.from_entity.as_str()).copied();
1947 let from_clean_file =
1948 from_file_path.is_some_and(|file_path| !stale_set.contains(file_path));
1949
1950 if (to_truly_changed || to_stale_file) && from_clean_file {
1951 affected_clean_ids.insert(edge.from_entity.clone());
1952 if let Some(file_path) = from_file_path {
1953 affected_clean_file_paths.insert(file_path);
1954 }
1955 }
1956 }
1957
1958 let mut affected_target_names: HashSet<&str> = all_entities
1959 .iter()
1960 .filter(|entity| {
1961 truly_changed_ids.contains(&entity.id)
1962 || parent_repaired_ids.contains(entity.id.as_str())
1963 })
1964 .map(|entity| entity.name.as_str())
1965 .collect();
1966 affected_target_names.extend(
1967 stale_file_cached_entities
1968 .iter()
1969 .filter(|entity| deleted_ids.contains(entity.id.as_str()))
1970 .map(|entity| entity.name.as_str()),
1971 );
1972
1973 if !affected_target_names.is_empty() {
1976 let affected_target_candidate_files: HashSet<&str> = affected_target_names
1977 .iter()
1978 .filter_map(|name| symbol_table.get(*name))
1979 .flatten()
1980 .filter_map(|entity_id| entity_file_paths.get(entity_id.as_str()).copied())
1981 .filter(|file_path| !stale_set.contains(*file_path))
1982 .collect();
1983
1984 for entity in all_entities.iter().filter(|entity| {
1985 affected_target_candidate_files.contains(entity.file_path.as_str())
1986 }) {
1987 if stale_set.contains(entity.file_path.as_str())
1988 || affected_clean_ids.contains(&entity.id)
1989 {
1990 continue;
1991 }
1992
1993 let ext = entity
1994 .file_path
1995 .rfind('.')
1996 .map(|i| &entity.file_path[i..])
1997 .unwrap_or("");
1998 if crate::parser::plugins::code::languages::get_language_config(ext).is_none() {
1999 continue;
2000 }
2001
2002 let extra = extra_ident_chars_for_file(&entity.file_path);
2003 if !text_mentions_any_name(&entity.content, &affected_target_names, extra) {
2004 continue;
2005 }
2006
2007 let stripped =
2008 strip_for_language(strip_strategy_for_file(&entity.file_path), &entity.content);
2009 if text_mentions_any_name(&stripped, &affected_target_names, extra) {
2010 affected_clean_ids.insert(entity.id.clone());
2011 affected_clean_file_paths.insert(entity.file_path.as_str());
2012 }
2013 }
2014 }
2015
2016 let import_table = if has_new_or_deleted_stale_entities {
2017 Some(build_import_table(
2018 root,
2019 all_file_paths,
2020 &symbol_table,
2021 &entity_map,
2022 Some(&parsed_files),
2023 ))
2024 } else {
2025 None
2026 };
2027
2028 let mut new_stale_entity_ids: HashSet<&str> = HashSet::default();
2029 let mut new_stale_names: HashSet<&str> = HashSet::default();
2030 for entity in &all_entities {
2031 if stale_set.contains(entity.file_path.as_str())
2032 && !cached_hashes.contains_key(entity.id.as_str())
2033 {
2034 new_stale_entity_ids.insert(entity.id.as_str());
2035 new_stale_names.insert(entity.name.as_str());
2036 }
2037 }
2038 if !new_stale_names.is_empty() {
2039 let import_table = import_table
2040 .as_ref()
2041 .expect("new stale entity analysis requires a full import table");
2042 let new_stale_import_refs: HashSet<(&str, &str)> = import_table
2043 .iter()
2044 .filter(|(_, target_id)| new_stale_entity_ids.contains(target_id.as_str()))
2045 .map(|((file_path, local_name), _)| (file_path.as_str(), local_name.as_str()))
2046 .collect();
2047 let new_stale_file_paths: HashSet<&str> = new_stale_entity_ids
2048 .iter()
2049 .filter_map(|entity_id| entity_file_paths.get(*entity_id).copied())
2050 .collect();
2051 let mut clean_import_candidate_files: HashSet<&str> = new_stale_import_refs
2052 .iter()
2053 .map(|(file_path, _)| *file_path)
2054 .collect();
2055 let mut clean_entities_mentioning_new_stale_names: HashSet<&str> = HashSet::default();
2056 for entity in all_entities
2057 .iter()
2058 .filter(|entity| !stale_set.contains(entity.file_path.as_str()))
2059 {
2060 let extra = extra_ident_chars_for_file(&entity.file_path);
2061 if !new_stale_names
2062 .iter()
2063 .any(|name| content_contains_identifier(&entity.content, name, extra))
2064 {
2065 continue;
2066 }
2067
2068 let stripped =
2069 strip_for_language(strip_strategy_for_file(&entity.file_path), &entity.content);
2070 if text_mentions_any_name(&stripped, &new_stale_names, extra) {
2071 clean_entities_mentioning_new_stale_names.insert(entity.id.as_str());
2072 clean_import_candidate_files.insert(entity.file_path.as_str());
2073 }
2074 }
2075
2076 let clean_file_import_tokens: HashMap<&str, Vec<String>> = clean_import_candidate_files
2077 .into_iter()
2078 .filter_map(|file_path| {
2079 let content = read_import_scan_prefix(&root.join(file_path))?;
2080 let mut tokens: Vec<String> = new_stale_file_paths
2081 .iter()
2082 .flat_map(|stale_file_path| {
2083 content_import_tokens_for_file(file_path, &content, stale_file_path)
2084 })
2085 .collect();
2086 if tokens.is_empty() {
2087 return None;
2088 }
2089 tokens.sort_unstable();
2090 tokens.dedup();
2091 Some((file_path, tokens))
2092 })
2093 .collect();
2094 let mut new_stale_import_refs_by_file: HashMap<&str, Vec<&str>> = HashMap::default();
2095 for (file_path, local_name) in &new_stale_import_refs {
2096 new_stale_import_refs_by_file
2097 .entry(*file_path)
2098 .or_default()
2099 .push(*local_name);
2100 }
2101
2102 for entity in all_entities
2103 .iter()
2104 .filter(|entity| !stale_set.contains(entity.file_path.as_str()))
2105 {
2106 if affected_clean_ids.contains(&entity.id) {
2107 continue;
2108 }
2109
2110 let entity_mentions_new_stale_name =
2111 clean_entities_mentioning_new_stale_names.contains(entity.id.as_str());
2112 if !entity_mentions_new_stale_name
2113 && !clean_file_import_tokens.contains_key(entity.file_path.as_str())
2114 && !new_stale_import_refs_by_file.contains_key(entity.file_path.as_str())
2115 {
2116 continue;
2117 }
2118
2119 let import_tokens = clean_file_import_tokens.get(entity.file_path.as_str());
2120 let mentions_new_stale_name = entity_mentions_new_stale_name;
2121 let extra = extra_ident_chars_for_file(&entity.file_path);
2122 let strip_strategy = strip_strategy_for_file(&entity.file_path);
2123 let mentions_new_stale_import_token = import_tokens.map_or(false, |tokens| {
2124 tokens
2125 .iter()
2126 .any(|token| content_contains_identifier(&entity.content, token, extra))
2127 });
2128 let imported_new_stale_ref = new_stale_import_refs_by_file
2129 .get(entity.file_path.as_str())
2130 .map_or(false, |local_names| {
2131 local_names.iter().any(|local_name| {
2132 content_contains_identifier(&entity.content, local_name, extra)
2133 })
2134 });
2135 let refs = extract_references_from_content(
2136 &entity.content,
2137 &entity.name,
2138 extra,
2139 strip_strategy,
2140 );
2141 if mentions_new_stale_name
2142 || mentions_new_stale_import_token
2143 || imported_new_stale_ref
2144 || refs.iter().any(|ref_name| {
2145 new_stale_names.contains(*ref_name)
2146 || new_stale_import_refs
2147 .contains(&(entity.file_path.as_str(), *ref_name))
2148 })
2149 {
2150 affected_clean_ids.insert(entity.id.clone());
2151 affected_clean_file_paths.insert(entity.file_path.as_str());
2152 }
2153 }
2154 }
2155
2156 let stale_js_ts_file_paths: Vec<&str> = stale_set
2157 .iter()
2158 .copied()
2159 .filter(|file_path| is_js_ts_file(file_path))
2160 .collect();
2161 if !stale_js_ts_file_paths.is_empty() {
2162 let clean_import_candidate_files: Vec<&str> = match cached_importing_stale_files {
2163 Some(files) => files
2164 .iter()
2165 .map(String::as_str)
2166 .filter(|file_path| !stale_set.contains(*file_path) && is_js_ts_file(file_path))
2167 .collect(),
2168 None => all_file_paths
2169 .iter()
2170 .map(String::as_str)
2171 .filter(|file_path| !stale_set.contains(*file_path) && is_js_ts_file(file_path))
2172 .collect(),
2173 };
2174 let clean_js_ts_import_tokens: HashMap<&str, Vec<String>> =
2175 clean_import_candidate_files
2176 .into_iter()
2177 .filter_map(|file_path| {
2178 let content = read_import_scan_prefix(&root.join(file_path))?;
2179 let mut tokens: Vec<String> = stale_js_ts_file_paths
2180 .iter()
2181 .flat_map(|stale_file_path| {
2182 content_import_tokens_for_file(file_path, &content, stale_file_path)
2183 })
2184 .collect();
2185 if tokens.is_empty() {
2186 return None;
2187 }
2188 tokens.sort_unstable();
2189 tokens.dedup();
2190 Some((file_path, tokens))
2191 })
2192 .collect();
2193
2194 for entity in all_entities
2195 .iter()
2196 .filter(|entity| !stale_set.contains(entity.file_path.as_str()))
2197 {
2198 if affected_clean_ids.contains(&entity.id) {
2199 continue;
2200 }
2201 let Some(tokens) = clean_js_ts_import_tokens.get(entity.file_path.as_str()) else {
2202 continue;
2203 };
2204 let extra = extra_ident_chars_for_file(&entity.file_path);
2205 if tokens
2206 .iter()
2207 .any(|token| content_contains_identifier(&entity.content, token, extra))
2208 {
2209 affected_clean_ids.insert(entity.id.clone());
2210 affected_clean_file_paths.insert(entity.file_path.as_str());
2211 }
2212 }
2213 }
2214
2215 let import_table = match import_table {
2216 Some(import_table) => import_table,
2217 None => {
2218 let mut file_paths = stale_files.to_vec();
2219 file_paths.extend(
2220 affected_clean_file_paths
2221 .iter()
2222 .map(|file_path| (*file_path).to_string()),
2223 );
2224 file_paths.sort_unstable();
2225 file_paths.dedup();
2226 build_import_table_with_default_export_paths(
2227 root,
2228 &file_paths,
2229 all_file_paths,
2230 &symbol_table,
2231 &entity_map,
2232 Some(&parsed_files),
2233 )
2234 }
2235 };
2236
2237 let kept_edges: Vec<EntityRef> = cached_edges
2242 .into_iter()
2243 .filter(|e| {
2244 if !current_entity_ids.contains(e.from_entity.as_str())
2245 || !current_entity_ids.contains(e.to_entity.as_str())
2246 {
2247 return false;
2248 }
2249
2250 let from_stale = stale_or_cached_stale_entity_ids.contains(e.from_entity.as_str());
2251 let to_stale = stale_or_cached_stale_entity_ids.contains(e.to_entity.as_str());
2252
2253 if !from_stale && !to_stale && !affected_clean_ids.contains(&e.from_entity) {
2254 return true;
2256 }
2257 false
2258 })
2259 .collect();
2260
2261 let needs_resolution: HashSet<&str> = all_entities
2265 .iter()
2266 .filter(|e| {
2267 truly_changed_ids.contains(&e.id)
2268 || content_clean_ids.contains(&e.id)
2269 || parent_repaired_ids.contains(e.id.as_str())
2270 || affected_clean_ids.contains(&e.id)
2271 })
2272 .map(|e| e.id.as_str())
2273 .collect();
2274
2275 let parent_child_pairs: HashSet<(&str, &str)> = all_entities
2280 .iter()
2281 .filter_map(|e| {
2282 e.parent_id
2283 .as_ref()
2284 .map(|pid| (pid.as_str(), e.id.as_str()))
2285 })
2286 .collect();
2287 let mut child_line_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::default();
2288 for entity in &all_entities {
2289 if let Some(pid) = &entity.parent_id {
2290 child_line_ranges
2291 .entry(pid.clone())
2292 .or_default()
2293 .push((entity.start_line, entity.end_line));
2294 }
2295 }
2296 for ranges in child_line_ranges.values_mut() {
2297 ranges.sort_unstable_by_key(|(start, end)| (*start, *end));
2298 }
2299
2300 let class_child_names: HashSet<(&str, &str)> = all_entities
2301 .iter()
2302 .filter_map(|e| {
2303 e.parent_id
2304 .as_ref()
2305 .map(|pid| (pid.as_str(), e.name.as_str()))
2306 })
2307 .collect();
2308
2309 let child_ranges_by_parent = build_child_ranges_by_parent(&all_entities);
2310
2311 let class_entity_names: HashSet<&str> = all_entities
2312 .iter()
2313 .filter(|e| is_nominal_member_container(e.entity_type.as_str()))
2314 .map(|e| e.name.as_str())
2315 .collect();
2316 let class_entity_files: HashSet<(&str, &str)> = all_entities
2317 .iter()
2318 .filter(|e| is_nominal_member_container(e.entity_type.as_str()))
2319 .map(|e| (e.name.as_str(), e.file_path.as_str()))
2320 .collect();
2321
2322 let id_to_name: HashMap<&str, &str> = all_entities
2323 .iter()
2324 .map(|e| (e.id.as_str(), e.name.as_str()))
2325 .collect();
2326
2327 let mut enclosing_class: HashMap<&str, &str> = HashMap::default();
2328 let mut class_members: HashMap<&str, Vec<(&str, &str)>> = HashMap::default();
2329 let mut scope_class_members: HashMap<String, Vec<(String, String)>> = HashMap::default();
2330 let mut scope_owner_members: HashMap<String, Vec<(String, String)>> = HashMap::default();
2331 let mut scope_entity_ranges: HashMap<String, Vec<(usize, usize, String)>> =
2332 HashMap::default();
2333
2334 for entity in &all_entities {
2335 scope_entity_ranges
2336 .entry(entity.file_path.clone())
2337 .or_default()
2338 .push((entity.start_line, entity.end_line, entity.id.clone()));
2339 if let Some(ref pid) = entity.parent_id {
2340 scope_owner_members
2341 .entry(pid.clone())
2342 .or_default()
2343 .push((entity.name.clone(), entity.id.clone()));
2344 if let Some(parent) = entity_map.get(pid.as_str()) {
2345 if let Some(owner_name) = scope_resolve::class_member_owner_name(parent) {
2346 scope_class_members
2347 .entry(owner_name.to_string())
2348 .or_default()
2349 .push((entity.name.clone(), entity.id.clone()));
2350 }
2351 }
2352 if let Some(&parent_name) = id_to_name.get(pid.as_str()) {
2353 if class_entity_names.contains(parent_name) {
2354 enclosing_class.insert(entity.id.as_str(), parent_name);
2355 class_members
2356 .entry(parent_name)
2357 .or_default()
2358 .push((entity.name.as_str(), entity.id.as_str()));
2359 }
2360 }
2361 }
2362 if entity.entity_type == "method" && entity.file_path.ends_with(".go") {
2363 if let Some(struct_name) = scope_resolve::extract_go_receiver_type(&entity.content)
2364 {
2365 scope_class_members
2366 .entry(struct_name)
2367 .or_default()
2368 .push((entity.name.clone(), entity.id.clone()));
2369 }
2370 }
2371 }
2372 for members in scope_class_members.values_mut() {
2373 members.sort_unstable();
2374 }
2375 for members in scope_owner_members.values_mut() {
2376 members.sort_unstable();
2377 }
2378 for ranges in scope_entity_ranges.values_mut() {
2379 ranges.sort_unstable();
2380 }
2381
2382 let resolve_file_paths: Vec<String> = all_file_paths
2384 .iter()
2385 .filter(|f| {
2386 stale_set.contains(f.as_str()) || affected_clean_file_paths.contains(f.as_str())
2387 })
2388 .cloned()
2389 .collect();
2390
2391 let has_scope_lang = resolve_file_paths.iter().any(|f| {
2392 let ext = f.rfind('.').map(|i| &f[i..]).unwrap_or("");
2393 crate::parser::plugins::code::languages::get_language_config(ext)
2394 .and_then(|c| c.scope_resolve)
2395 .is_some()
2396 });
2397 let (scope_edges, scope_consumed_words) = if has_scope_lang {
2398 let resolve_set: HashSet<&str> =
2400 resolve_file_paths.iter().map(|s| s.as_str()).collect();
2401 let relevant_parsed: Vec<(String, String, tree_sitter::Tree)> = parsed_files
2402 .into_iter()
2403 .filter(|(fp, _, _)| resolve_set.contains(fp.as_str()))
2404 .collect();
2405 let pre = if relevant_parsed.is_empty() {
2406 None
2407 } else {
2408 Some(relevant_parsed)
2409 };
2410 let owned_go_pkg_index: HashMap<String, Vec<(String, String)>> =
2411 if resolve_file_paths.iter().any(|f| f.ends_with(".go")) {
2412 scope_resolve::build_go_pkg_index(&symbol_table, &entity_map)
2413 } else {
2414 HashMap::default()
2415 };
2416 let pre_built = scope_resolve::PreBuiltLookups {
2417 symbol_table: Arc::clone(&symbol_table),
2418 class_members: scope_class_members,
2419 owner_members: scope_owner_members,
2420 entity_ranges: scope_entity_ranges,
2421 go_pkg_index: owned_go_pkg_index,
2422 };
2423 let result = scope_resolve::resolve_with_scopes_full(
2424 root,
2425 &resolve_file_paths,
2426 &all_entities,
2427 &entity_map,
2428 pre,
2429 Some(&pre_built),
2430 Some(&import_table),
2431 false,
2432 );
2433 (result.edges, result.consumed_words)
2434 } else {
2435 (vec![], HashMap::default())
2436 };
2437
2438 let imports_by_file = build_imports_by_file(&import_table);
2439 let reference_context = ReferenceResolutionContext {
2440 symbol_table: symbol_table.as_ref(),
2441 entity_map: &entity_map,
2442 imports_by_file: &imports_by_file,
2443 scope_consumed_words: &scope_consumed_words,
2444 child_ranges_by_parent: &child_ranges_by_parent,
2445 child_line_ranges: &child_line_ranges,
2446 parent_child_pairs: &parent_child_pairs,
2447 class_child_names: &class_child_names,
2448 class_entity_files: &class_entity_files,
2449 enclosing_class: &enclosing_class,
2450 class_members: &class_members,
2451 };
2452 let resolved_refs = resolve_references_with_file_indexes(
2453 root,
2454 &resolve_file_paths,
2455 &all_entities,
2456 Some(&needs_resolution),
2457 &reference_context,
2458 );
2459
2460 let export_edges = build_export_alias_edges(&all_entities, &import_table);
2461
2462 let mut combined: Vec<(String, String, RefType)> = scope_edges;
2464 combined.extend(export_edges);
2465 combined.extend(resolved_refs);
2466 let mut all_resolved = dedupe_resolved_edges(combined);
2467 sort_resolved_refs(&mut all_resolved);
2468
2469 let mut edges: Vec<EntityRef> = Vec::with_capacity(kept_edges.len() + all_resolved.len());
2471
2472 let mut kept_edge_pairs: HashSet<(&str, &str)> =
2473 HashSet::with_capacity_and_hasher(kept_edges.len(), Default::default());
2474 for edge in &kept_edges {
2475 kept_edge_pairs.insert((edge.from_entity.as_str(), edge.to_entity.as_str()));
2476 }
2477
2478 let mut new_edges: Vec<(String, String, RefType)> = Vec::with_capacity(all_resolved.len());
2479 for (from_entity, to_entity, ref_type) in all_resolved {
2480 if kept_edge_pairs.contains(&(from_entity.as_str(), to_entity.as_str())) {
2481 continue;
2482 }
2483 new_edges.push((from_entity, to_entity, ref_type));
2484 }
2485 drop(kept_edge_pairs);
2486
2487 for edge in kept_edges {
2488 edges.push(edge);
2489 }
2490
2491 for (from_entity, to_entity, ref_type) in new_edges {
2492 edges.push(EntityRef {
2493 from_entity,
2494 to_entity,
2495 ref_type,
2496 });
2497 }
2498
2499 let graph = EntityGraph::from_parts(entity_map.into_iter().collect(), edges);
2500
2501 let mut recomputed_edge_source_ids: Vec<String> = needs_resolution
2502 .iter()
2503 .map(|id| (*id).to_string())
2504 .collect();
2505 recomputed_edge_source_ids.sort_unstable();
2506 recomputed_edge_source_ids.dedup();
2507
2508 let mut deleted_entity_ids: Vec<String> =
2509 deleted_ids.iter().map(|id| (*id).to_string()).collect();
2510 deleted_entity_ids.sort_unstable();
2511 deleted_entity_ids.dedup();
2512
2513 (
2514 graph,
2515 all_entities,
2516 IncrementalBuildMetadata {
2517 repaired_clean_entity_ids,
2518 recomputed_edge_source_ids,
2519 deleted_entity_ids,
2520 },
2521 )
2522 }
2523
2524 pub fn get_dependents(&self, entity_id: &str) -> Vec<&EntityInfo> {
2526 self.dependents
2527 .get(entity_id)
2528 .map(|ids| ids.iter().filter_map(|id| self.entities.get(id)).collect())
2529 .unwrap_or_default()
2530 }
2531
2532 pub fn get_dependencies(&self, entity_id: &str) -> Vec<&EntityInfo> {
2534 self.dependencies
2535 .get(entity_id)
2536 .map(|ids| ids.iter().filter_map(|id| self.entities.get(id)).collect())
2537 .unwrap_or_default()
2538 }
2539
2540 pub fn impact_analysis(&self, entity_id: &str) -> Vec<&EntityInfo> {
2543 self.impact_analysis_capped(entity_id, 10_000)
2544 }
2545
2546 pub fn impact_analysis_bounded(
2549 &self,
2550 entity_id: &str,
2551 max_depth: usize,
2552 ) -> Vec<(&EntityInfo, usize)> {
2553 let mut visited: HashSet<&str> = HashSet::default();
2554 let mut queue: std::collections::VecDeque<(&str, usize)> =
2555 std::collections::VecDeque::new();
2556 let mut result = Vec::new();
2557
2558 let start_key = match self.entities.get_key_value(entity_id) {
2559 Some((k, _)) => k.as_str(),
2560 None => return result,
2561 };
2562
2563 queue.push_back((start_key, 0));
2564 visited.insert(start_key);
2565
2566 while let Some((current, depth)) = queue.pop_front() {
2567 if let Some(deps) = self.dependents.get(current) {
2568 let next_depth = depth + 1;
2569 if max_depth > 0 && next_depth > max_depth {
2570 continue;
2571 }
2572 for dep in deps {
2573 if visited.insert(dep.as_str()) {
2574 if let Some(info) = self.entities.get(dep.as_str()) {
2575 result.push((info, next_depth));
2576 }
2577 queue.push_back((dep.as_str(), next_depth));
2578 }
2579 }
2580 }
2581 }
2582
2583 result
2584 }
2585
2586 pub fn impact_analysis_capped(&self, entity_id: &str, max_visited: usize) -> Vec<&EntityInfo> {
2589 let mut visited: HashSet<&str> = HashSet::default();
2590 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
2591 let mut result = Vec::new();
2592
2593 let start_key = match self.entities.get_key_value(entity_id) {
2594 Some((k, _)) => k.as_str(),
2595 None => return result,
2596 };
2597
2598 queue.push_back(start_key);
2599 visited.insert(start_key);
2600
2601 while let Some(current) = queue.pop_front() {
2602 if result.len() >= max_visited {
2603 break;
2604 }
2605 if let Some(deps) = self.dependents.get(current) {
2606 for dep in deps {
2607 if visited.insert(dep.as_str()) {
2608 if let Some(info) = self.entities.get(dep.as_str()) {
2609 result.push(info);
2610 }
2611 queue.push_back(dep.as_str());
2612 if result.len() >= max_visited {
2613 break;
2614 }
2615 }
2616 }
2617 }
2618 }
2619
2620 result
2621 }
2622
2623 pub fn impact_count(&self, entity_id: &str, max_count: usize) -> usize {
2626 let mut visited: HashSet<&str> = HashSet::default();
2627 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
2628 let mut count = 0;
2629
2630 let start_key = match self.entities.get_key_value(entity_id) {
2632 Some((k, _)) => k.as_str(),
2633 None => return 0,
2634 };
2635
2636 queue.push_back(start_key);
2637 visited.insert(start_key);
2638
2639 while let Some(current) = queue.pop_front() {
2640 if count >= max_count {
2641 break;
2642 }
2643 if let Some(deps) = self.dependents.get(current) {
2644 for dep in deps {
2645 if visited.insert(dep.as_str()) {
2646 count += 1;
2647 queue.push_back(dep.as_str());
2648 if count >= max_count {
2649 break;
2650 }
2651 }
2652 }
2653 }
2654 }
2655
2656 count
2657 }
2658
2659 pub fn filter_test_entities(
2662 &self,
2663 entities: &[crate::model::entity::SemanticEntity],
2664 ) -> StdHashSet<String> {
2665 self.filter_test_entities_with_custom_dirs(entities, &[])
2666 }
2667
2668 pub fn filter_test_entities_with_custom_dirs(
2671 &self,
2672 entities: &[crate::model::entity::SemanticEntity],
2673 custom_test_dirs: &[String],
2674 ) -> StdHashSet<String> {
2675 let mut test_ids = StdHashSet::new();
2676 for entity in entities {
2677 if is_test_entity(entity, custom_test_dirs) {
2678 test_ids.insert(entity.id.clone());
2679 }
2680 }
2681 test_ids
2682 }
2683
2684 pub fn test_impact(
2687 &self,
2688 entity_id: &str,
2689 all_entities: &[crate::model::entity::SemanticEntity],
2690 ) -> Vec<&EntityInfo> {
2691 self.test_impact_with_custom_dirs(entity_id, all_entities, &[])
2692 }
2693
2694 pub fn test_impact_with_custom_dirs(
2697 &self,
2698 entity_id: &str,
2699 all_entities: &[crate::model::entity::SemanticEntity],
2700 custom_test_dirs: &[String],
2701 ) -> Vec<&EntityInfo> {
2702 let test_ids = self.filter_test_entities_with_custom_dirs(all_entities, custom_test_dirs);
2703 let impact = self.impact_analysis(entity_id);
2704 impact
2705 .into_iter()
2706 .filter(|info| test_ids.contains(&info.id))
2707 .collect()
2708 }
2709
2710 pub fn update_from_changes(
2721 &mut self,
2722 changed_files: &[FileChange],
2723 root: &Path,
2724 registry: &ParserRegistry,
2725 ) {
2726 let mut affected_files: HashSet<String> = HashSet::default();
2727 let mut new_entities: Vec<SemanticEntity> = Vec::new();
2728
2729 for change in changed_files {
2730 affected_files.insert(change.file_path.clone());
2731 if let Some(ref old_path) = change.old_file_path {
2732 affected_files.insert(old_path.clone());
2733 }
2734
2735 match change.status {
2736 FileStatus::Deleted => {
2737 self.remove_entities_for_file(&change.file_path);
2738 }
2739 FileStatus::Renamed => {
2740 if let Some(ref old_path) = change.old_file_path {
2742 self.remove_entities_for_file(old_path);
2743 }
2744 if let Some(entities) = self.extract_file_entities(
2746 &change.file_path,
2747 change.after_content.as_deref(),
2748 root,
2749 registry,
2750 ) {
2751 new_entities.extend(entities);
2752 }
2753 }
2754 FileStatus::Added | FileStatus::Modified => {
2755 self.remove_entities_for_file(&change.file_path);
2757 if let Some(entities) = self.extract_file_entities(
2759 &change.file_path,
2760 change.after_content.as_deref(),
2761 root,
2762 registry,
2763 ) {
2764 new_entities.extend(entities);
2765 }
2766 }
2767 }
2768 }
2769
2770 for entity in &new_entities {
2772 self.entities.insert(
2773 entity.id.clone(),
2774 EntityInfo {
2775 id: entity.id.clone(),
2776 name: entity.name.clone(),
2777 entity_type: entity.entity_type.clone(),
2778 file_path: entity.file_path.clone(),
2779 parent_id: entity.parent_id.clone(),
2780 start_line: entity.start_line,
2781 end_line: entity.end_line,
2782 },
2783 );
2784 }
2785
2786 let symbol_table = self.build_symbol_table();
2788 let child_ranges_by_parent = build_child_ranges_by_parent(&new_entities);
2789
2790 for entity in &new_entities {
2792 self.resolve_entity_references(entity, &symbol_table, &child_ranges_by_parent);
2793 }
2794
2795 let changed_entity_names: HashSet<String> =
2798 new_entities.iter().map(|e| e.name.clone()).collect();
2799
2800 let entities_to_recheck: Vec<String> = self
2802 .entities
2803 .values()
2804 .filter(|e| !affected_files.contains(&e.file_path))
2805 .filter(|e| {
2806 self.dependencies.get(&e.id).map_or(false, |deps| {
2807 deps.iter().any(|dep_id| {
2808 self.entities
2809 .get(dep_id)
2810 .map_or(false, |dep| changed_entity_names.contains(&dep.name))
2811 })
2812 })
2813 })
2814 .map(|e| e.id.clone())
2815 .collect();
2816
2817 let _ = entities_to_recheck; }
2824
2825 fn extract_file_entities(
2827 &self,
2828 file_path: &str,
2829 content: Option<&str>,
2830 root: &Path,
2831 registry: &ParserRegistry,
2832 ) -> Option<Vec<SemanticEntity>> {
2833 let content = if let Some(c) = content {
2834 c.to_string()
2835 } else {
2836 let full_path = root.join(file_path);
2837 std::fs::read_to_string(&full_path).ok()?
2838 };
2839
2840 Some(registry.extract_entities(file_path, &content))
2841 }
2842
2843 fn remove_entities_for_file(&mut self, file_path: &str) {
2845 let ids_to_remove: Vec<String> = self
2847 .entities
2848 .values()
2849 .filter(|e| e.file_path == file_path)
2850 .map(|e| e.id.clone())
2851 .collect();
2852
2853 let id_set: HashSet<&str> = ids_to_remove.iter().map(|s| s.as_str()).collect();
2854
2855 for id in &ids_to_remove {
2857 self.entities.remove(id);
2858 }
2859
2860 self.edges.retain(|e| {
2862 !id_set.contains(e.from_entity.as_str()) && !id_set.contains(e.to_entity.as_str())
2863 });
2864
2865 for id in &ids_to_remove {
2867 if let Some(deps) = self.dependencies.remove(id) {
2869 for dep in &deps {
2871 if let Some(dependents) = self.dependents.get_mut(dep) {
2872 dependents.retain(|d| d != id);
2873 }
2874 }
2875 }
2876 if let Some(deps) = self.dependents.remove(id) {
2878 for dep in &deps {
2880 if let Some(dependencies) = self.dependencies.get_mut(dep) {
2881 dependencies.retain(|d| d != id);
2882 }
2883 }
2884 }
2885 }
2886 }
2887
2888 fn build_symbol_table(&self) -> HashMap<String, Vec<String>> {
2890 let mut symbol_table: HashMap<String, Vec<String>> = HashMap::default();
2891 let mut entities = self.entities.values().collect::<Vec<_>>();
2892 entities.sort_unstable_by(|left, right| {
2893 left.file_path
2894 .cmp(&right.file_path)
2895 .then_with(|| left.start_line.cmp(&right.start_line))
2896 .then_with(|| left.end_line.cmp(&right.end_line))
2897 .then_with(|| left.id.cmp(&right.id))
2898 });
2899 for entity in entities {
2900 symbol_table
2901 .entry(entity.name.clone())
2902 .or_default()
2903 .push(entity.id.clone());
2904 }
2905 symbol_table
2906 }
2907
2908 fn resolve_entity_references(
2910 &mut self,
2911 entity: &SemanticEntity,
2912 symbol_table: &HashMap<String, Vec<String>>,
2913 child_ranges_by_parent: &HashMap<&str, Vec<ChildRange<'_>>>,
2914 ) {
2915 let stripped = strip_comments_and_strings(&entity.content);
2916 let refs = extract_references_with_stripped_filtered(
2917 &entity.content,
2918 &entity.name,
2919 &stripped,
2920 extra_ident_chars_for_file(&entity.file_path),
2921 |local_line, local_start_byte, local_end_byte| {
2922 entity_owns_content_span(
2923 entity.id.as_str(),
2924 entity.file_path.as_str(),
2925 source_line_for_entity_content(entity, local_line),
2926 Some(local_start_byte),
2927 Some(local_end_byte),
2928 child_ranges_by_parent,
2929 )
2930 },
2931 );
2932
2933 for ref_name in refs {
2934 if let Some(target_ids) = symbol_table.get(ref_name) {
2935 let target = target_ids
2936 .iter()
2937 .find(|id| {
2938 *id != &entity.id
2939 && self
2940 .entities
2941 .get(*id)
2942 .map_or(false, |e| e.file_path == entity.file_path)
2943 })
2944 .or_else(|| target_ids.iter().find(|id| *id != &entity.id));
2945
2946 if let Some(target_id) = target {
2947 let ref_type = infer_ref_type(&entity.content, &ref_name);
2948 self.edges.push(EntityRef {
2949 from_entity: entity.id.clone(),
2950 to_entity: target_id.clone(),
2951 ref_type,
2952 });
2953 self.dependents
2954 .entry(target_id.clone())
2955 .or_default()
2956 .push(entity.id.clone());
2957 self.dependencies
2958 .entry(entity.id.clone())
2959 .or_default()
2960 .push(target_id.clone());
2961 }
2962 }
2963 }
2964 }
2965}
2966
2967fn is_nominal_member_container(entity_type: &str) -> bool {
2968 matches!(
2969 entity_type,
2970 "class" | "struct" | "interface" | "class_type" | "enum" | "protocol"
2971 )
2972}
2973
2974#[cfg(test)]
2975fn is_scope_member_container(entity_type: &str) -> bool {
2976 matches!(
2977 entity_type,
2978 "class"
2979 | "struct"
2980 | "interface"
2981 | "impl"
2982 | "enum"
2983 | "protocol"
2984 | "object_declaration"
2985 | "companion_object"
2986 )
2987}
2988
2989fn is_test_entity(entity: &crate::model::entity::SemanticEntity, custom_test_dirs: &[String]) -> bool {
2991 let name = &entity.name;
2992 let content = &entity.content;
2993
2994 if name.starts_with("test_")
2996 || name.starts_with("Test")
2997 || name.ends_with("_test")
2998 || name.ends_with("Test")
2999 {
3000 return true;
3001 }
3002 if name.starts_with("it_") || name.starts_with("describe_") || name.starts_with("spec_") {
3003 return true;
3004 }
3005
3006 let in_test_file =
3008 crate::parser::test_detect::is_test_path_with_custom_dirs(&entity.file_path, custom_test_dirs);
3009
3010 let has_test_marker = content.contains("#[test]")
3012 || content.contains("#[cfg(test)]")
3013 || content.contains("@Test")
3014 || content.contains("@pytest")
3015 || content.contains("@test")
3016 || content.contains("describe(")
3017 || content.contains("it(")
3018 || content.contains("test(");
3019
3020 in_test_file && has_test_marker
3021}
3022
3023fn build_export_alias_edges(
3024 all_entities: &[SemanticEntity],
3025 import_table: &HashMap<(String, String), String>,
3026) -> Vec<(String, String, RefType)> {
3027 all_entities
3028 .iter()
3029 .filter(|entity| entity.entity_type == "export")
3030 .filter_map(|entity| {
3031 let key = (entity.file_path.clone(), entity.name.clone());
3032 let target_id = import_table.get(&key)?;
3033 if target_id == &entity.id {
3034 return None;
3035 }
3036 Some((entity.id.clone(), target_id.clone(), RefType::Imports))
3037 })
3038 .collect()
3039}
3040
3041struct TsDefaultExportTable {
3042 exports_by_file: HashMap<String, String>,
3043 sorted_files: Vec<String>,
3044}
3045
3046struct TsTopLevelEntityTable {
3047 entities_by_file: HashMap<String, Vec<(String, String)>>,
3048 sorted_files: Vec<String>,
3049}
3050
3051struct TsDefaultReExport {
3052 file_path: String,
3053 original_name: String,
3054 module_path: String,
3055}
3056
3057struct PendingDefaultImport {
3058 file_path: String,
3059 local_name: String,
3060 module_path: String,
3061}
3062
3063struct PendingNamespaceImport {
3064 file_path: String,
3065 alias: String,
3066 module_path: String,
3067}
3068
3069struct ImportFileScan {
3070 default_export: Option<(String, String)>,
3071 default_re_exports: Vec<TsDefaultReExport>,
3072 named_exports: Option<(String, HashSet<String>)>,
3073 local_imports: Vec<((String, String), String)>,
3074 default_imports: Vec<PendingDefaultImport>,
3075 namespace_imports: Vec<PendingNamespaceImport>,
3076 re_export_imports: Vec<((String, String), String)>,
3077}
3078
3079fn sorted_default_export_files(default_exports: &HashMap<String, String>) -> Vec<String> {
3080 let mut sorted_files: Vec<String> = default_exports.keys().cloned().collect();
3081 sort_import_candidate_files(&mut sorted_files, JS_TS_EXTENSIONS);
3082 sorted_files
3083}
3084
3085fn build_ts_top_level_entity_table(
3086 entity_map: &HashMap<String, EntityInfo>,
3087) -> TsTopLevelEntityTable {
3088 let mut entities_by_file: HashMap<String, Vec<(String, String)>> = HashMap::default();
3089 for entity in entity_map.values() {
3090 if !is_js_ts_file(&entity.file_path) || entity.parent_id.is_some() {
3091 continue;
3092 }
3093 entities_by_file
3094 .entry(entity.file_path.clone())
3095 .or_default()
3096 .push((entity.name.clone(), entity.id.clone()));
3097 }
3098 for entries in entities_by_file.values_mut() {
3099 entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
3100 }
3101 let mut sorted_files: Vec<String> = entities_by_file.keys().cloned().collect();
3102 sort_import_candidate_files(&mut sorted_files, JS_TS_EXTENSIONS);
3103 TsTopLevelEntityTable {
3104 entities_by_file,
3105 sorted_files,
3106 }
3107}
3108
3109fn resolve_ts_default_re_exports(
3110 default_exports: &mut HashMap<String, String>,
3111 pending: Vec<TsDefaultReExport>,
3112 symbol_table: &HashMap<String, Vec<String>>,
3113 entity_map: &HashMap<String, EntityInfo>,
3114) {
3115 let mut pending = pending;
3116 while !pending.is_empty() {
3117 let sorted_files = sorted_default_export_files(default_exports);
3118 let mut unresolved = Vec::new();
3119 let mut progressed = false;
3120
3121 for re_export in pending {
3122 let target_id = if re_export.original_name == "default" {
3123 find_import_file(
3124 &sorted_files,
3125 &re_export.module_path,
3126 &re_export.file_path,
3127 JS_TS_EXTENSIONS,
3128 )
3129 .and_then(|target_file| default_exports.get(target_file))
3130 .cloned()
3131 } else {
3132 symbol_table
3133 .get(&re_export.original_name)
3134 .and_then(|target_ids| {
3135 find_import_target(
3136 target_ids,
3137 &re_export.module_path,
3138 &re_export.file_path,
3139 JS_TS_EXTENSIONS,
3140 entity_map,
3141 )
3142 .cloned()
3143 })
3144 };
3145
3146 if let Some(target_id) = target_id {
3147 default_exports.insert(re_export.file_path, target_id);
3148 progressed = true;
3149 } else {
3150 unresolved.push(re_export);
3151 }
3152 }
3153
3154 if !progressed {
3155 break;
3156 }
3157 pending = unresolved;
3158 }
3159}
3160
3161fn default_export_names_from_content(content: &str) -> Vec<String> {
3162 static DEFAULT_FUNCTION_RE: LazyLock<Regex> = LazyLock::new(|| {
3163 Regex::new(r"\bexport\s+default\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)")
3164 .unwrap()
3165 });
3166 static DEFAULT_CLASS_RE: LazyLock<Regex> = LazyLock::new(|| {
3167 Regex::new(r"\bexport\s+default\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)").unwrap()
3168 });
3169 static DEFAULT_IDENTIFIER_RE: LazyLock<Regex> =
3170 LazyLock::new(|| Regex::new(r"\bexport\s+default\s+([A-Za-z_$][\w$]*)").unwrap());
3171 static DEFAULT_SPECIFIER_RE: LazyLock<Regex> =
3172 LazyLock::new(|| Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*;?"#).unwrap());
3173
3174 let mut names = Vec::new();
3175 for cap in DEFAULT_FUNCTION_RE.captures_iter(content) {
3176 names.push(cap.get(1).unwrap().as_str().to_string());
3177 }
3178 for cap in DEFAULT_CLASS_RE.captures_iter(content) {
3179 names.push(cap.get(1).unwrap().as_str().to_string());
3180 }
3181 for cap in DEFAULT_IDENTIFIER_RE.captures_iter(content) {
3182 let name = cap.get(1).unwrap();
3183 let line_tail = content[name.end()..]
3184 .split_once('\n')
3185 .map_or(&content[name.end()..], |(line, _)| line);
3186 if only_js_ts_statement_trivia(line_tail) {
3187 names.push(name.as_str().to_string());
3188 }
3189 }
3190 for cap in DEFAULT_SPECIFIER_RE.captures_iter(content) {
3191 let rest = content[cap.get(0).unwrap().end()..].trim_start();
3192 if rest.starts_with("from ") {
3193 continue;
3194 }
3195 let names_str = cap.get(1).unwrap().as_str();
3196 for name_part in names_str.split(',') {
3197 let Some((original_name, local_name)) = parse_js_ts_import_specifier(name_part) else {
3198 continue;
3199 };
3200 if local_name == "default" {
3201 names.push(original_name.to_string());
3202 }
3203 }
3204 }
3205
3206 names
3207}
3208
3209fn default_re_exports_from_content(content: &str) -> Vec<(String, String)> {
3210 static REEXPORT_SPECIFIER_RE: LazyLock<Regex> = LazyLock::new(|| {
3211 Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#).unwrap()
3212 });
3213
3214 let mut re_exports = Vec::new();
3215 for cap in REEXPORT_SPECIFIER_RE.captures_iter(content) {
3216 let names_str = cap.get(1).unwrap().as_str();
3217 let module_path = cap.get(2).unwrap().as_str();
3218 for name_part in names_str.split(',') {
3219 let Some((original_name, local_name)) = parse_js_ts_import_specifier(name_part) else {
3220 continue;
3221 };
3222 if local_name == "default" {
3223 re_exports.push((original_name.to_string(), module_path.to_string()));
3224 }
3225 }
3226 }
3227 re_exports
3228}
3229
3230fn only_js_ts_statement_trivia(mut text: &str) -> bool {
3231 loop {
3232 text = text.trim_start();
3233 if let Some(rest) = text.strip_prefix(';') {
3234 text = rest;
3235 continue;
3236 }
3237 if text.is_empty() {
3238 return true;
3239 }
3240 if text.starts_with("//") {
3241 return true;
3242 }
3243 if let Some(rest) = text.strip_prefix("/*") {
3244 let Some(end) = rest.find("*/") else {
3245 return false;
3246 };
3247 text = &rest[end + 2..];
3248 continue;
3249 }
3250 return false;
3251 }
3252}
3253
3254fn resolve_default_export_target(
3255 default_exports: &TsDefaultExportTable,
3256 module_path: &str,
3257 file_path: &str,
3258) -> Option<String> {
3259 let target_file = find_import_file(
3260 &default_exports.sorted_files,
3261 module_path,
3262 file_path,
3263 JS_TS_EXTENSIONS,
3264 )?;
3265 default_exports.exports_by_file.get(target_file).cloned()
3266}
3267
3268fn parse_js_ts_import_specifier(name_part: &str) -> Option<(&str, &str)> {
3269 let name_part = name_part.trim();
3270 if name_part.is_empty() {
3271 return None;
3272 }
3273
3274 let (original, local) = if let Some(pos) = name_part.find(" as ") {
3275 let original = name_part[..pos].trim();
3276 let local = name_part[pos + 4..].trim();
3277 (original, local)
3278 } else {
3279 (name_part, name_part)
3280 };
3281
3282 let original = original.strip_prefix("type ").unwrap_or(original).trim();
3283 let local = local.strip_prefix("type ").unwrap_or(local).trim();
3284 if original.is_empty() || local.is_empty() {
3285 return None;
3286 }
3287
3288 Some((original, local))
3289}
3290
3291fn import_source_content<'a>(
3292 root: &Path,
3293 pre_parsed_content: &HashMap<&'a str, &'a str>,
3294 file_path: &str,
3295) -> Option<Cow<'a, str>> {
3296 if let Some(content) = pre_parsed_content.get(file_path) {
3297 Some(Cow::Borrowed(*content))
3298 } else {
3299 std::fs::read_to_string(root.join(file_path))
3300 .ok()
3301 .map(Cow::Owned)
3302 }
3303}
3304
3305fn same_file_set(left: &[String], right: &[String]) -> bool {
3306 if left.len() != right.len() {
3307 return false;
3308 }
3309
3310 let left_set: HashSet<&str> = left.iter().map(String::as_str).collect();
3311 right
3312 .iter()
3313 .all(|file_path| left_set.contains(file_path.as_str()))
3314}
3315
3316fn scan_import_file(
3317 file_path: &str,
3318 content: &str,
3319 parse_imports: bool,
3320 symbol_table: &HashMap<String, Vec<String>>,
3321 entity_map: &HashMap<String, EntityInfo>,
3322 clojure_ns_index: &ClojureNsIndex,
3323) -> ImportFileScan {
3324 let mut scan = ImportFileScan {
3325 default_export: None,
3326 default_re_exports: Vec::new(),
3327 named_exports: None,
3328 local_imports: Vec::new(),
3329 default_imports: Vec::new(),
3330 namespace_imports: Vec::new(),
3331 re_export_imports: Vec::new(),
3332 };
3333
3334 let is_js_ts = is_js_ts_file(file_path);
3335 if is_js_ts {
3336 for name in default_export_names_from_content(content) {
3337 let Some(target_ids) = symbol_table.get(name.as_str()) else {
3338 continue;
3339 };
3340 let target = target_ids.iter().find(|id| {
3341 entity_map.get(*id).map_or(false, |entity| {
3342 entity.file_path == file_path && entity.parent_id.is_none()
3343 })
3344 });
3345 if let Some(target_id) = target {
3346 scan.default_export = Some((file_path.to_string(), target_id.clone()));
3347 }
3348 }
3349
3350 scan.default_re_exports = default_re_exports_from_content(content)
3351 .into_iter()
3352 .map(|(original_name, module_path)| TsDefaultReExport {
3353 file_path: file_path.to_string(),
3354 original_name,
3355 module_path,
3356 })
3357 .collect();
3358 scan.named_exports = Some((
3359 file_path.to_string(),
3360 js_ts_named_exports_from_content(content)
3361 .into_iter()
3362 .collect(),
3363 ));
3364 }
3365
3366 if !parse_imports {
3367 return scan;
3368 }
3369
3370 let mut logical_lines: Vec<String> = Vec::new();
3372 let mut current_line = String::new();
3373 let mut in_parens = false;
3374
3375 for line in content.lines() {
3376 let trimmed = line.trim();
3377 if in_parens {
3378 let clean = trimmed.trim_end_matches(|c: char| c == ')' || c == ',');
3379 let clean = clean.split('#').next().unwrap_or(clean).trim();
3380 if !clean.is_empty() && clean != "(" {
3381 current_line.push_str(", ");
3382 current_line.push_str(clean);
3383 }
3384 if trimmed.contains(')') {
3385 in_parens = false;
3386 logical_lines.push(std::mem::take(&mut current_line));
3387 }
3388 } else if trimmed.starts_with("from ") && trimmed.contains(" import ") {
3389 if trimmed.contains('(') && !trimmed.contains(')') {
3390 in_parens = true;
3391 let before_paren = trimmed.split('(').next().unwrap_or(trimmed);
3392 current_line = before_paren.trim().to_string();
3393 if let Some(after) = trimmed.split('(').nth(1) {
3394 let after = after.trim().trim_end_matches(')').trim();
3395 if !after.is_empty() {
3396 current_line.push(' ');
3397 current_line.push_str(after);
3398 }
3399 }
3400 } else {
3401 logical_lines.push(trimmed.to_string());
3402 }
3403 }
3404 }
3405
3406 for logical_line in &logical_lines {
3407 if let Some(rest) = logical_line.strip_prefix("from ") {
3408 let import_match = rest
3409 .find(" import ")
3410 .map(|pos| (pos, 8))
3411 .or_else(|| rest.find(" import,").map(|pos| (pos, 8)));
3412 if let Some((import_pos, skip)) = import_match {
3413 let module_path = &rest[..import_pos];
3414 let names_str = &rest[import_pos + skip..];
3415
3416 for name_part in names_str.split(',') {
3417 let name_part = name_part.trim();
3418 let imported_name = name_part.split_whitespace().next().unwrap_or(name_part);
3419 let imported_name =
3420 imported_name.trim_matches(|c: char| c == '(' || c == ')' || c == ',');
3421 if imported_name.is_empty() {
3422 continue;
3423 }
3424
3425 if let Some(target_ids) = symbol_table.get(imported_name) {
3426 let target = find_import_target(
3427 target_ids,
3428 module_path,
3429 file_path,
3430 &[".py"],
3431 entity_map,
3432 );
3433 if let Some(target_id) = target {
3434 scan.local_imports.push((
3435 (file_path.to_string(), imported_name.to_string()),
3436 target_id.clone(),
3437 ));
3438 }
3439 }
3440 }
3441 }
3442 }
3443 }
3444
3445 if is_js_ts {
3446 static JS_NAMED_RE: LazyLock<Regex> = LazyLock::new(|| {
3447 Regex::new(
3448 r#"import\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#,
3449 )
3450 .unwrap()
3451 });
3452 static JS_DEFAULT_RE: LazyLock<Regex> = LazyLock::new(|| {
3453 Regex::new(
3454 r#"import\s+(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s*,\s*\{[^}]*\})?\s*from\s*['"]([^'"]+)['"]"#,
3455 )
3456 .unwrap()
3457 });
3458 static JS_REEXPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
3459 Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#).unwrap()
3460 });
3461 static JS_NAMESPACE_RE: LazyLock<Regex> = LazyLock::new(|| {
3462 Regex::new(r#"import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]"#)
3463 .unwrap()
3464 });
3465
3466 for cap in JS_NAMED_RE.captures_iter(content) {
3467 let names_str = cap.get(1).unwrap().as_str();
3468 let module_path = cap.get(2).unwrap().as_str();
3469
3470 for name_part in names_str.split(',') {
3471 let Some((original_name, local_name)) = parse_js_ts_import_specifier(name_part)
3472 else {
3473 continue;
3474 };
3475
3476 if let Some(target_ids) = symbol_table.get(original_name) {
3477 let target = find_import_target(
3478 target_ids,
3479 module_path,
3480 file_path,
3481 JS_TS_EXTENSIONS,
3482 entity_map,
3483 );
3484 if let Some(target_id) = target {
3485 scan.local_imports.push((
3486 (file_path.to_string(), local_name.to_string()),
3487 target_id.clone(),
3488 ));
3489 }
3490 }
3491 }
3492 }
3493
3494 for cap in JS_DEFAULT_RE.captures_iter(content) {
3495 scan.default_imports.push(PendingDefaultImport {
3496 file_path: file_path.to_string(),
3497 local_name: cap.get(1).unwrap().as_str().to_string(),
3498 module_path: cap.get(2).unwrap().as_str().to_string(),
3499 });
3500 }
3501
3502 for cap in JS_NAMESPACE_RE.captures_iter(content) {
3503 scan.namespace_imports.push(PendingNamespaceImport {
3504 file_path: file_path.to_string(),
3505 alias: cap.get(1).unwrap().as_str().to_string(),
3506 module_path: cap.get(2).unwrap().as_str().to_string(),
3507 });
3508 }
3509
3510 for cap in JS_REEXPORT_RE.captures_iter(content) {
3511 let names_str = cap.get(1).unwrap().as_str();
3512 let module_path = cap.get(2).unwrap().as_str();
3513
3514 for name_part in names_str.split(',') {
3515 let Some((original_name, local_name)) = parse_js_ts_import_specifier(name_part)
3516 else {
3517 continue;
3518 };
3519
3520 if original_name == "default" {
3521 scan.default_imports.push(PendingDefaultImport {
3522 file_path: file_path.to_string(),
3523 local_name: local_name.to_string(),
3524 module_path: module_path.to_string(),
3525 });
3526 } else if let Some(target_id) =
3527 symbol_table.get(original_name).and_then(|target_ids| {
3528 find_import_target(
3529 target_ids,
3530 module_path,
3531 file_path,
3532 JS_TS_EXTENSIONS,
3533 entity_map,
3534 )
3535 .cloned()
3536 })
3537 {
3538 scan.re_export_imports
3539 .push(((file_path.to_string(), local_name.to_string()), target_id));
3540 }
3541 }
3542 }
3543 }
3544
3545 if file_path.ends_with(".rs") {
3546 static RUST_USE_SIMPLE_RE: LazyLock<Regex> = LazyLock::new(|| {
3547 Regex::new(
3548 r"(?m)^\s*use\s+(?:(?:crate|super|self)::)?([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*;",
3549 )
3550 .unwrap()
3551 });
3552 static RUST_USE_GROUP_RE: LazyLock<Regex> = LazyLock::new(|| {
3553 Regex::new(r"(?m)^\s*use\s+(?:(?:crate|super|self)::)?([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)::\{([^}]+)\}\s*;").unwrap()
3554 });
3555 let mut local_import_table: HashMap<(String, String), String> = HashMap::default();
3556
3557 for cap in RUST_USE_SIMPLE_RE.captures_iter(content) {
3558 let full_path_str = cap.get(1).unwrap().as_str();
3559 let parts: Vec<&str> = full_path_str.split("::").collect();
3560 if parts.is_empty() {
3561 continue;
3562 }
3563 let imported_name = parts[parts.len() - 1];
3564 let source_module = if parts.len() >= 2 {
3565 parts[parts.len() - 2]
3566 } else {
3567 parts[0]
3568 };
3569 resolve_rust_import(
3570 file_path,
3571 imported_name,
3572 source_module,
3573 symbol_table,
3574 entity_map,
3575 &mut local_import_table,
3576 );
3577 }
3578
3579 for cap in RUST_USE_GROUP_RE.captures_iter(content) {
3580 let module_path = cap.get(1).unwrap().as_str();
3581 let names_str = cap.get(2).unwrap().as_str();
3582 let source_module = module_path.rsplit("::").next().unwrap_or(module_path);
3583
3584 for name_part in names_str.split(',') {
3585 let name_part = name_part.trim();
3586 let (original, local) = if let Some(pos) = name_part.find(" as ") {
3587 (&name_part[..pos], name_part[pos + 4..].trim())
3588 } else {
3589 (name_part, name_part)
3590 };
3591 let original = original.trim();
3592 let local = local.trim();
3593 if original.is_empty() || local.is_empty() {
3594 continue;
3595 }
3596
3597 resolve_rust_import(
3598 file_path,
3599 original,
3600 source_module,
3601 symbol_table,
3602 entity_map,
3603 &mut local_import_table,
3604 );
3605 if local != original {
3606 if let Some(target) = local_import_table
3607 .get(&(file_path.to_string(), original.to_string()))
3608 .cloned()
3609 {
3610 local_import_table
3611 .insert((file_path.to_string(), local.to_string()), target);
3612 }
3613 }
3614 }
3615 }
3616
3617 scan.local_imports.extend(local_import_table);
3618 }
3619
3620 let file_ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
3621 if let Some(file_config) =
3622 crate::parser::plugins::code::languages::get_language_config(file_ext)
3623 {
3624 if file_config.has_slash_qualified_refs() {
3625 let clojure_stripped = strip_for_language(file_config.strip_strategy(), content);
3626 for cap in CLOJURE_REFER_RE.captures_iter(&clojure_stripped) {
3627 let ns_name = cap.get(1).unwrap().as_str();
3628 let symbols_str = cap.get(2).unwrap().as_str();
3629 for symbol in symbols_str.split_whitespace() {
3630 let symbol =
3631 symbol.trim_matches(|c: char| c == ',' || c == '(' || c == ')');
3632 if symbol.is_empty() {
3633 continue;
3634 }
3635 resolve_clojure_require(
3636 file_path,
3637 ns_name,
3638 symbol,
3639 symbol_table,
3640 entity_map,
3641 &mut scan.local_imports,
3642 );
3643 }
3644 }
3645 for cap in CLOJURE_AS_RE.captures_iter(&clojure_stripped) {
3646 let ns_name = cap.get(1).unwrap().as_str();
3647 let alias = cap.get(2).unwrap().as_str();
3648 resolve_clojure_as(
3649 file_path,
3650 ns_name,
3651 alias,
3652 clojure_ns_index,
3653 &mut scan.local_imports,
3654 );
3655 }
3656 }
3657 }
3658
3659 scan
3660}
3661
3662fn build_import_table(
3667 root: &Path,
3668 file_paths: &[String],
3669 symbol_table: &HashMap<String, Vec<String>>,
3670 entity_map: &HashMap<String, EntityInfo>,
3671 pre_parsed_content: Option<&[(String, String, tree_sitter::Tree)]>,
3672) -> HashMap<(String, String), String> {
3673 build_import_table_with_default_export_paths(
3674 root,
3675 file_paths,
3676 file_paths,
3677 symbol_table,
3678 entity_map,
3679 pre_parsed_content,
3680 )
3681}
3682
3683fn build_import_table_with_default_export_paths(
3684 root: &Path,
3685 file_paths: &[String],
3686 default_export_file_paths: &[String],
3687 symbol_table: &HashMap<String, Vec<String>>,
3688 entity_map: &HashMap<String, EntityInfo>,
3689 pre_parsed_content: Option<&[(String, String, tree_sitter::Tree)]>,
3690) -> HashMap<(String, String), String> {
3691 let mut pre_parsed_content_map: HashMap<&str, &str> = HashMap::default();
3692 if let Some(files) = pre_parsed_content {
3693 pre_parsed_content_map.extend(
3694 files
3695 .iter()
3696 .map(|(fp, content, _)| (fp.as_str(), content.as_str())),
3697 );
3698 }
3699 let import_source_set: HashSet<&str> = file_paths.iter().map(String::as_str).collect();
3700 let clojure_ns_index = build_clojure_ns_index(entity_map);
3701 let mut content_file_set: HashSet<String> = file_paths.iter().cloned().collect();
3702 let mut pre_scanned_files: HashMap<String, ImportFileScan> = HashMap::default();
3703 if same_file_set(file_paths, default_export_file_paths) {
3704 content_file_set.extend(default_export_file_paths.iter().cloned());
3705 } else {
3706 let mut content_file_queue: Vec<String> = file_paths.to_vec();
3707 while let Some(file_path) = content_file_queue.pop() {
3708 if file_path.ends_with(".go") {
3709 continue;
3710 }
3711 if pre_scanned_files.contains_key(file_path.as_str()) {
3712 continue;
3713 }
3714
3715 let Some(content) = import_source_content(root, &pre_parsed_content_map, &file_path)
3716 else {
3717 continue;
3718 };
3719 for imported_file in js_ts_import_source_files_from_content(
3720 &file_path,
3721 content.as_ref(),
3722 default_export_file_paths,
3723 ) {
3724 if content_file_set.insert(imported_file.clone()) {
3725 content_file_queue.push(imported_file);
3726 }
3727 }
3728 pre_scanned_files.insert(
3729 file_path.clone(),
3730 scan_import_file(
3731 &file_path,
3732 content.as_ref(),
3733 import_source_set.contains(file_path.as_str()),
3734 symbol_table,
3735 entity_map,
3736 &clojure_ns_index,
3737 ),
3738 );
3739 }
3740 }
3741 let mut content_file_paths: Vec<String> = content_file_set.into_iter().collect();
3742 content_file_paths.sort_unstable();
3743
3744 let mut scans: Vec<ImportFileScan> = if pre_scanned_files.is_empty() {
3745 maybe_par_iter!(content_file_paths)
3746 .filter_map(|file_path| {
3747 if file_path.ends_with(".go") {
3748 return None;
3749 }
3750
3751 let content = import_source_content(root, &pre_parsed_content_map, file_path)?;
3752 Some(scan_import_file(
3753 file_path,
3754 content.as_ref(),
3755 import_source_set.contains(file_path.as_str()),
3756 symbol_table,
3757 entity_map,
3758 &clojure_ns_index,
3759 ))
3760 })
3761 .collect()
3762 } else {
3763 let mut scans = Vec::with_capacity(content_file_paths.len());
3764 for file_path in &content_file_paths {
3765 if file_path.ends_with(".go") {
3766 continue;
3767 }
3768 if let Some(scan) = pre_scanned_files.remove(file_path.as_str()) {
3769 scans.push(scan);
3770 continue;
3771 }
3772
3773 let Some(content) = import_source_content(root, &pre_parsed_content_map, file_path)
3774 else {
3775 continue;
3776 };
3777 scans.push(scan_import_file(
3778 file_path,
3779 content.as_ref(),
3780 import_source_set.contains(file_path.as_str()),
3781 symbol_table,
3782 entity_map,
3783 &clojure_ns_index,
3784 ));
3785 }
3786 scans
3787 };
3788
3789 let mut default_exports = HashMap::default();
3790 let mut re_exports = Vec::new();
3791 let mut named_exports_by_file: HashMap<String, HashSet<String>> = HashMap::default();
3792 for scan in &mut scans {
3793 if let Some((file_path, target_id)) = scan.default_export.take() {
3794 default_exports.insert(file_path, target_id);
3795 }
3796 re_exports.append(&mut scan.default_re_exports);
3797 if let Some((file_path, named_exports)) = scan.named_exports.take() {
3798 named_exports_by_file.insert(file_path, named_exports);
3799 }
3800 }
3801 resolve_ts_default_re_exports(&mut default_exports, re_exports, symbol_table, entity_map);
3802 let ts_default_exports = TsDefaultExportTable {
3803 sorted_files: sorted_default_export_files(&default_exports),
3804 exports_by_file: default_exports,
3805 };
3806 let ts_top_level_entities = OnceLock::new();
3807
3808 let mut import_table: HashMap<(String, String), String> = HashMap::default();
3809 for scan in scans {
3810 for (key, val) in scan.local_imports {
3811 import_table.insert(key, val);
3812 }
3813 for pending in scan.default_imports {
3814 if let Some(target_id) = resolve_default_export_target(
3815 &ts_default_exports,
3816 &pending.module_path,
3817 &pending.file_path,
3818 ) {
3819 import_table.insert((pending.file_path, pending.local_name), target_id);
3820 }
3821 }
3822 for pending in scan.namespace_imports {
3823 let ts_top_level_entities =
3824 ts_top_level_entities.get_or_init(|| build_ts_top_level_entity_table(entity_map));
3825 let Some(target_file) = find_import_file(
3826 &ts_top_level_entities.sorted_files,
3827 &pending.module_path,
3828 &pending.file_path,
3829 JS_TS_EXTENSIONS,
3830 ) else {
3831 continue;
3832 };
3833 let Some(entries) = ts_top_level_entities.entities_by_file.get(target_file) else {
3834 continue;
3835 };
3836 let Some(exported_names) = named_exports_by_file.get(target_file) else {
3837 continue;
3838 };
3839 for (name, target_id) in entries {
3840 if !exported_names.contains(name) {
3841 continue;
3842 }
3843 import_table.insert(
3844 (
3845 pending.file_path.clone(),
3846 format!("{}.{}", pending.alias, name),
3847 ),
3848 target_id.clone(),
3849 );
3850 }
3851 }
3852 for (key, val) in scan.re_export_imports {
3853 import_table.insert(key, val);
3854 }
3855 }
3856
3857 import_table
3858}
3859
3860fn resolve_rust_import(
3863 file_path: &str,
3864 imported_name: &str,
3865 source_module: &str,
3866 symbol_table: &HashMap<String, Vec<String>>,
3867 entity_map: &HashMap<String, EntityInfo>,
3868 import_table: &mut HashMap<(String, String), String>,
3869) {
3870 if let Some(target_ids) = symbol_table.get(imported_name) {
3871 let target = target_ids.iter().find(|id| {
3872 entity_map.get(*id).map_or(false, |e| {
3873 let stem = e.file_path.rsplit('/').next().unwrap_or(&e.file_path);
3874 let stem = strip_file_ext(stem);
3875 stem == source_module
3876 })
3877 });
3878 if let Some(target_id) = target {
3879 import_table.insert(
3880 (file_path.to_string(), imported_name.to_string()),
3881 target_id.clone(),
3882 );
3883 }
3884 }
3885}
3886
3887type ClojureNsIndex = HashMap<String, Vec<(String, String)>>;
3891
3892fn build_clojure_ns_index(entity_map: &HashMap<String, EntityInfo>) -> ClojureNsIndex {
3893 let mut index: ClojureNsIndex = HashMap::default();
3894 for (entity_id, entity_info) in entity_map {
3895 let fp = &entity_info.file_path;
3896 if !fp.ends_with(".clj") && !fp.ends_with(".cljs") && !fp.ends_with(".cljc") {
3897 continue;
3898 }
3899 let path_no_ext = fp.rsplit_once('.').map(|(p, _)| p).unwrap_or(fp.as_str());
3900 index
3901 .entry(path_no_ext.to_string())
3902 .or_default()
3903 .push((entity_info.name.clone(), entity_id.clone()));
3904 }
3905 index
3906}
3907
3908fn resolve_clojure_require(
3912 file_path: &str,
3913 ns_name: &str,
3914 symbol: &str,
3915 symbol_table: &HashMap<String, Vec<String>>,
3916 entity_map: &HashMap<String, EntityInfo>,
3917 local_imports: &mut Vec<((String, String), String)>,
3918) {
3919 if ns_name.is_empty() {
3920 return;
3921 }
3922 let Some(target_ids) = symbol_table.get(symbol) else {
3923 return;
3924 };
3925 let ns_path = ns_name.replace('.', "/").replace('-', "_");
3927 let suffix_clj = format!("{ns_path}.clj");
3929 let suffix_cljs = format!("{ns_path}.cljs");
3930 let suffix_cljc = format!("{ns_path}.cljc");
3931 let ns_matches = |fp: &str, suffix: &str| fp == suffix || fp.ends_with(&format!("/{suffix}"));
3934 let target = target_ids.iter().find(|id| {
3935 entity_map.get(*id).map_or(false, |e| {
3936 let fp = &e.file_path;
3937 ns_matches(fp, &suffix_clj)
3938 || ns_matches(fp, &suffix_cljs)
3939 || ns_matches(fp, &suffix_cljc)
3940 })
3941 });
3942 if let Some(target_id) = target {
3943 local_imports.push((
3944 (file_path.to_string(), symbol.to_string()),
3945 target_id.clone(),
3946 ));
3947 }
3948}
3949
3950fn resolve_clojure_as(
3955 file_path: &str,
3956 ns_name: &str,
3957 alias: &str,
3958 ns_index: &ClojureNsIndex,
3959 local_imports: &mut Vec<((String, String), String)>,
3960) {
3961 let ns_path = ns_name.replace('.', "/").replace('-', "_");
3962 let ns_path_suffix = format!("/{}", ns_path);
3963 for (path_no_ext, entities) in ns_index {
3964 if path_no_ext == &ns_path || path_no_ext.ends_with(&ns_path_suffix) {
3965 for (entity_name, entity_id) in entities {
3966 local_imports.push((
3967 (file_path.to_string(), format!("{}/{}", alias, entity_name)),
3968 entity_id.clone(),
3969 ));
3970 }
3971 }
3972 }
3973}
3974
3975fn strip_clojure_line_comments(s: &str) -> String {
3979 let mut result = s
3983 .lines()
3984 .map(|line| line.find(';').map_or(line, |pos| &line[..pos]))
3985 .collect::<Vec<_>>()
3986 .join("\n");
3987 if s.ends_with('\n') {
3988 result.push('\n');
3989 }
3990 result
3991}
3992
3993fn strip_file_ext(s: &str) -> &str {
3995 s.strip_suffix(".py")
3996 .or_else(|| s.strip_suffix(".ts"))
3997 .or_else(|| s.strip_suffix(".js"))
3998 .or_else(|| s.strip_suffix(".tsx"))
3999 .or_else(|| s.strip_suffix(".jsx"))
4000 .or_else(|| s.strip_suffix(".rs"))
4001 .unwrap_or(s)
4002}
4003
4004fn blank_span_preserving_newlines(result: &mut [u8], bytes: &[u8], start: usize, end: usize) {
4007 for idx in start..end.min(bytes.len()) {
4008 result[idx] = if bytes[idx] == b'\n' { b'\n' } else { b' ' };
4009 }
4010}
4011
4012fn strip_comments_and_strings(content: &str) -> String {
4013 let bytes = content.as_bytes();
4014 let len = bytes.len();
4015 let mut result = vec![b' '; len];
4016 let mut i = 0;
4017
4018 while i < len {
4019 if i + 2 < len && bytes[i] == b'"' && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
4021 let span_start = i;
4022 i += 3;
4023 while i < len {
4024 if i + 2 < len && bytes[i] == b'"' && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
4025 i += 3;
4026 break;
4027 }
4028 if bytes[i] == b'\n' {
4029 result[i] = b'\n';
4030 }
4031 i += 1;
4032 }
4033 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4034 continue;
4035 }
4036 if i + 2 < len && bytes[i] == b'\'' && bytes[i + 1] == b'\'' && bytes[i + 2] == b'\'' {
4037 let span_start = i;
4038 i += 3;
4039 while i < len {
4040 if i + 2 < len
4041 && bytes[i] == b'\''
4042 && bytes[i + 1] == b'\''
4043 && bytes[i + 2] == b'\''
4044 {
4045 i += 3;
4046 break;
4047 }
4048 if bytes[i] == b'\n' {
4049 result[i] = b'\n';
4050 }
4051 i += 1;
4052 }
4053 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4054 continue;
4055 }
4056 if bytes[i] == b'"' {
4058 let span_start = i;
4059 i += 1;
4060 while i < len {
4061 if bytes[i] == b'\\' {
4062 i = (i + 2).min(len);
4063 continue;
4064 }
4065 if bytes[i] == b'"' {
4066 i += 1;
4067 break;
4068 }
4069 if bytes[i] == b'\n' {
4070 result[i] = b'\n';
4071 }
4072 i += 1;
4073 }
4074 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4075 continue;
4076 }
4077 if bytes[i] == b'\'' {
4079 let span_start = i;
4080 i += 1;
4081 while i < len {
4082 if bytes[i] == b'\\' {
4083 i = (i + 2).min(len);
4084 continue;
4085 }
4086 if bytes[i] == b'\'' {
4087 i += 1;
4088 break;
4089 }
4090 if bytes[i] == b'\n' {
4091 result[i] = b'\n';
4092 }
4093 i += 1;
4094 }
4095 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4096 continue;
4097 }
4098 if bytes[i] == b'#' {
4100 let span_start = i;
4101 while i < len && bytes[i] != b'\n' {
4102 i += 1;
4103 }
4104 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4105 continue;
4106 }
4107 if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' {
4109 let span_start = i;
4110 while i < len && bytes[i] != b'\n' {
4111 i += 1;
4112 }
4113 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4114 continue;
4115 }
4116 if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
4118 let span_start = i;
4119 i += 2;
4120 while i < len {
4121 if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
4122 i += 2;
4123 break;
4124 }
4125 i += 1;
4126 }
4127 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4128 continue;
4129 }
4130 result[i] = bytes[i];
4132 i += 1;
4133 }
4134
4135 String::from_utf8(result).expect("stripped source preserves UTF-8 boundaries")
4136}
4137
4138fn strip_clojure_content(content: &str) -> String {
4148 let bytes = content.as_bytes();
4149 let len = bytes.len();
4150 let mut result = vec![b' '; len];
4151 let mut i = 0;
4152
4153 while i < len {
4154 if bytes[i] == b'"' {
4156 let span_start = i;
4157 i += 1;
4158 while i < len {
4159 if bytes[i] == b'\\' {
4160 i = (i + 2).min(len);
4161 continue;
4162 }
4163 if bytes[i] == b'"' {
4164 i += 1;
4165 break;
4166 }
4167 i += 1;
4168 }
4169 blank_span_preserving_newlines(&mut result, bytes, span_start, i);
4170 continue;
4171 }
4172 result[i] = bytes[i];
4174 i += 1;
4175 }
4176
4177 String::from_utf8(result).expect("stripped source preserves UTF-8 boundaries")
4178}
4179
4180fn strip_for_language(
4183 strategy: crate::parser::plugins::code::languages::StripStrategy,
4184 content: &str,
4185) -> String {
4186 use crate::parser::plugins::code::languages::StripStrategy;
4187 match strategy {
4188 StripStrategy::Generic => strip_comments_and_strings(content),
4189 StripStrategy::Clojure => strip_clojure_line_comments(&strip_clojure_content(content)),
4190 }
4191}
4192
4193fn extract_dot_chains<'a>(content: &'a str) -> Vec<(&'a str, &'a str)> {
4196 extract_dot_chains_with_positions(content)
4197 .into_iter()
4198 .map(|(receiver, member, _, _, _)| (receiver, member))
4199 .collect()
4200}
4201
4202fn extract_dot_chains_with_positions<'a>(
4204 content: &'a str,
4205) -> Vec<(&'a str, &'a str, usize, usize, usize)> {
4206 static DOT_CHAIN_RE: LazyLock<Regex> =
4207 LazyLock::new(|| Regex::new(r"\b([A-Za-z_]\w*)\.([A-Za-z_]\w*)").unwrap());
4208
4209 let mut chains = Vec::new();
4210 let mut seen: HashSet<(&str, &str, usize, usize)> = HashSet::default();
4211 for cap in DOT_CHAIN_RE.captures_iter(content) {
4212 let matched = cap.get(0).unwrap();
4213 let line = line_for_byte(content, matched.start());
4214 let receiver = cap.get(1).unwrap().as_str();
4215 let member = cap.get(2).unwrap().as_str();
4216 if seen.insert((receiver, member, line, matched.start())) {
4217 chains.push((receiver, member, line, matched.start(), matched.end()));
4218 }
4219 }
4220 chains
4221}
4222
4223fn local_binding_names_filtered<F>(
4224 content: &str,
4225 ext: &str,
4226 mut include_token: F,
4227) -> HashSet<String>
4228where
4229 F: FnMut(usize, usize, usize) -> bool,
4230{
4231 let mut names = HashSet::default();
4232 if !matches!(ext, ".js" | ".jsx" | ".ts" | ".tsx" | ".py" | ".swift") {
4233 return names;
4234 }
4235
4236 let mut line_no = 1;
4237 let mut line_start = 0;
4238 for chunk in content.split_inclusive('\n') {
4239 let line = chunk.strip_suffix('\n').unwrap_or(chunk);
4240 match ext {
4241 ".js" | ".jsx" | ".ts" | ".tsx" | ".swift" => {
4242 collect_local_binding_captures(
4243 line,
4244 line_no,
4245 line_start,
4246 &JS_TS_SWIFT_LOCAL_DECL_RE,
4247 &mut include_token,
4248 &mut names,
4249 );
4250 }
4251 ".py" => {
4252 collect_python_local_bindings(
4253 line,
4254 line_no,
4255 line_start,
4256 &mut include_token,
4257 &mut names,
4258 );
4259 }
4260 _ => {}
4261 }
4262 line_start += chunk.len();
4263 line_no += 1;
4264 }
4265
4266 names
4267}
4268
4269static JS_TS_SWIFT_LOCAL_DECL_RE: LazyLock<Regex> =
4270 LazyLock::new(|| Regex::new(r"\b(?:const|let|var)\s+([A-Za-z_]\w*)").unwrap());
4271
4272static PY_LOCAL_ASSIGN_RE: LazyLock<Regex> =
4273 LazyLock::new(|| Regex::new(r"^\s*([A-Za-z_]\w*)\s*(?::[^=]+)?([+\-*/%&|^]?=)").unwrap());
4274
4275static PY_FOR_BINDING_RE: LazyLock<Regex> =
4276 LazyLock::new(|| Regex::new(r"^\s*for\s+([A-Za-z_]\w*)\s+in\b").unwrap());
4277
4278static CLOJURE_REFER_RE: LazyLock<Regex> = LazyLock::new(|| {
4282 Regex::new(r"\[([a-zA-Z][a-zA-Z0-9._-]*)\b[^\[\]]*:refer\s+\[([^\]]+)\]").unwrap()
4283});
4284
4285static CLOJURE_AS_RE: LazyLock<Regex> = LazyLock::new(|| {
4288 Regex::new(r"\[([a-zA-Z][a-zA-Z0-9._-]*)\b[^\]]*:as\s+([a-zA-Z][a-zA-Z0-9_-]*)").unwrap()
4289});
4290
4291static CLOJURE_QUALIFIED_REF_RE: LazyLock<Regex> = LazyLock::new(|| {
4294 Regex::new(r"\b([a-zA-Z][a-zA-Z0-9_?!=*-]*/[a-zA-Z][a-zA-Z0-9_?!=*-]*)").unwrap()
4295});
4296
4297fn collect_local_binding_captures<F>(
4298 line: &str,
4299 line_no: usize,
4300 line_start: usize,
4301 regex: &Regex,
4302 include_token: &mut F,
4303 names: &mut HashSet<String>,
4304) where
4305 F: FnMut(usize, usize, usize) -> bool,
4306{
4307 for cap in regex.captures_iter(line) {
4308 if let Some(name_match) = cap.get(1) {
4309 maybe_add_local_binding_name(
4310 name_match.as_str(),
4311 line_no,
4312 line_start,
4313 name_match,
4314 include_token,
4315 names,
4316 );
4317 }
4318 }
4319}
4320
4321fn collect_python_local_bindings<F>(
4322 line: &str,
4323 line_no: usize,
4324 line_start: usize,
4325 include_token: &mut F,
4326 names: &mut HashSet<String>,
4327) where
4328 F: FnMut(usize, usize, usize) -> bool,
4329{
4330 if let Some(cap) = PY_LOCAL_ASSIGN_RE.captures(line) {
4331 if let (Some(name_match), Some(op_match)) = (cap.get(1), cap.get(2)) {
4332 if line.as_bytes().get(op_match.end()) != Some(&b'=') {
4333 maybe_add_local_binding_name(
4334 name_match.as_str(),
4335 line_no,
4336 line_start,
4337 name_match,
4338 include_token,
4339 names,
4340 );
4341 }
4342 }
4343 }
4344
4345 collect_local_binding_captures(
4346 line,
4347 line_no,
4348 line_start,
4349 &PY_FOR_BINDING_RE,
4350 include_token,
4351 names,
4352 );
4353}
4354
4355fn maybe_add_local_binding_name<F>(
4356 name: &str,
4357 line_no: usize,
4358 line_start: usize,
4359 name_match: regex::Match<'_>,
4360 include_token: &mut F,
4361 names: &mut HashSet<String>,
4362) where
4363 F: FnMut(usize, usize, usize) -> bool,
4364{
4365 if !is_reference_word(name) {
4366 return;
4367 }
4368 let start = line_start + name_match.start();
4369 let end = line_start + name_match.end();
4370 if include_token(line_no, start, end) {
4371 names.insert(name.to_string());
4372 }
4373}
4374
4375fn extra_ident_chars_for_file(file_path: &str) -> &'static [char] {
4378 let ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
4379 crate::parser::plugins::code::languages::get_language_config(ext)
4380 .map_or(&[], |c| c.extra_ident_chars())
4381}
4382
4383fn strip_strategy_for_file(
4384 file_path: &str,
4385) -> crate::parser::plugins::code::languages::StripStrategy {
4386 let ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
4387 crate::parser::plugins::code::languages::get_language_config(ext).map_or(
4388 crate::parser::plugins::code::languages::StripStrategy::Generic,
4389 |c| c.strip_strategy(),
4390 )
4391}
4392
4393fn extract_references_from_content<'a>(
4397 content: &'a str,
4398 own_name: &str,
4399 extra_ident_chars: &'static [char],
4400 strip_strategy: crate::parser::plugins::code::languages::StripStrategy,
4401) -> Vec<&'a str> {
4402 let stripped = strip_for_language(strip_strategy, content);
4403 extract_references_with_stripped(content, own_name, &stripped, extra_ident_chars)
4404}
4405
4406fn token_iter<'a>(text: &'a str, extra: &'static [char]) -> impl Iterator<Item = &'a str> + 'a {
4410 let mut token_start: Option<usize> = None;
4411 let mut char_iter = text.char_indices();
4412 std::iter::from_fn(move || loop {
4413 match char_iter.next() {
4414 None => {
4415 return token_start.take().map(|s| &text[s..]);
4416 }
4417 Some((idx, ch)) => {
4418 if ch.is_alphanumeric() || ch == '_' || extra.contains(&ch) {
4419 token_start.get_or_insert(idx);
4420 } else if let Some(s) = token_start.take() {
4421 return Some(&text[s..idx]);
4422 }
4423 }
4424 }
4425 })
4426}
4427
4428fn text_mentions_any_name(
4429 text: &str,
4430 names: &HashSet<&str>,
4431 extra_ident_chars: &'static [char],
4432) -> bool {
4433 token_iter(text, extra_ident_chars).any(|t| names.contains(t))
4434}
4435
4436fn content_contains_identifier(
4437 content: &str,
4438 identifier: &str,
4439 extra_ident_chars: &'static [char],
4440) -> bool {
4441 token_iter(content, extra_ident_chars).any(|t| t == identifier)
4442}
4443
4444const IMPORT_SCAN_PREFIX_LINES: usize = 80;
4445
4446fn read_import_scan_prefix(path: &Path) -> Option<String> {
4447 let file = std::fs::File::open(path).ok()?;
4448 let mut content = String::new();
4449 for line in std::io::BufReader::new(file)
4450 .lines()
4451 .take(IMPORT_SCAN_PREFIX_LINES)
4452 {
4453 content.push_str(&line.ok()?);
4454 content.push('\n');
4455 }
4456 Some(content)
4457}
4458
4459fn content_import_tokens_for_file(
4460 importing_file_path: &str,
4461 content: &str,
4462 candidate_file_path: &str,
4463) -> Vec<String> {
4464 let mut tokens = Vec::new();
4465
4466 if importing_file_path.ends_with(".py") {
4467 for line in content.lines() {
4468 let trimmed = line.split('#').next().unwrap_or("").trim();
4469 if let Some(rest) = trimmed.strip_prefix("from ") {
4470 let Some(import_pos) = rest.find(" import ") else {
4471 continue;
4472 };
4473 let source_path = rest[..import_pos].trim();
4474 if !import_source_matches_file(
4475 importing_file_path,
4476 source_path,
4477 &[".py"],
4478 candidate_file_path,
4479 ) {
4480 continue;
4481 }
4482
4483 let names = rest[import_pos + " import ".len()..].trim();
4484 for import_part in names.split(',') {
4485 let import_part = import_part
4486 .trim()
4487 .trim_matches(|c: char| c == '(' || c == ')' || c == ',');
4488 if import_part.is_empty() {
4489 continue;
4490 }
4491 let (original, local) = split_import_alias(import_part);
4492 push_import_token(&mut tokens, original);
4493 push_import_token(&mut tokens, local);
4494 }
4495 } else if let Some(rest) = trimmed.strip_prefix("import ") {
4496 for import_part in rest.split(',') {
4497 let import_part = import_part.trim();
4498 let (source_path, alias) = split_import_alias(import_part);
4499 let source_path = source_path.split_whitespace().next().unwrap_or("").trim();
4500 if source_path.is_empty()
4501 || !import_source_matches_file(
4502 importing_file_path,
4503 source_path,
4504 &[".py"],
4505 candidate_file_path,
4506 )
4507 {
4508 continue;
4509 }
4510
4511 let default_local = source_path.split('.').next().unwrap_or(source_path);
4512 push_import_token(&mut tokens, alias);
4513 push_import_token(&mut tokens, default_local);
4514 }
4515 }
4516 }
4517 }
4518
4519 if importing_file_path.ends_with(".js")
4520 || importing_file_path.ends_with(".ts")
4521 || importing_file_path.ends_with(".jsx")
4522 || importing_file_path.ends_with(".tsx")
4523 {
4524 static JS_NAMED_IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4525 Regex::new(
4526 r#"import\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#,
4527 )
4528 .unwrap()
4529 });
4530 static JS_NAMESPACE_IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4531 Regex::new(r#"import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]"#)
4532 .unwrap()
4533 });
4534 static JS_DEFAULT_IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4535 Regex::new(
4536 r#"import\s+(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s*,\s*\{[^}]*\})?\s*from\s*['"]([^'"]+)['"]"#,
4537 )
4538 .unwrap()
4539 });
4540 static JS_REEXPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4541 Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#).unwrap()
4542 });
4543
4544 for cap in JS_NAMED_IMPORT_RE.captures_iter(content) {
4545 let names = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4546 let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4547 if !import_source_matches_file(
4548 importing_file_path,
4549 source_path,
4550 &[".ts", ".tsx", ".js", ".jsx"],
4551 candidate_file_path,
4552 ) {
4553 continue;
4554 }
4555 for name_part in names.split(',') {
4556 let name_part = name_part.trim();
4557 let name_part = name_part.strip_prefix("type ").unwrap_or(name_part);
4558 let (original, local) = split_import_alias(name_part);
4559 push_import_token(&mut tokens, original);
4560 push_import_token(&mut tokens, local);
4561 }
4562 }
4563
4564 for cap in JS_NAMESPACE_IMPORT_RE.captures_iter(content) {
4565 let alias = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4566 let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4567 if import_source_matches_file(
4568 importing_file_path,
4569 source_path,
4570 &[".ts", ".tsx", ".js", ".jsx"],
4571 candidate_file_path,
4572 ) {
4573 push_import_token(&mut tokens, alias);
4574 }
4575 }
4576
4577 for cap in JS_DEFAULT_IMPORT_RE.captures_iter(content) {
4578 let local = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4579 let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4580 if import_source_matches_file(
4581 importing_file_path,
4582 source_path,
4583 &[".ts", ".tsx", ".js", ".jsx"],
4584 candidate_file_path,
4585 ) {
4586 push_import_token(&mut tokens, local);
4587 }
4588 }
4589
4590 for cap in JS_REEXPORT_RE.captures_iter(content) {
4591 let names = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4592 let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4593 if !import_source_matches_file(
4594 importing_file_path,
4595 source_path,
4596 &[".ts", ".tsx", ".js", ".jsx"],
4597 candidate_file_path,
4598 ) {
4599 continue;
4600 }
4601 for name_part in names.split(',') {
4602 let name_part = name_part.trim();
4603 let name_part = name_part.strip_prefix("type ").unwrap_or(name_part);
4604 let (original, local) = split_import_alias(name_part);
4605 push_import_token(&mut tokens, original);
4606 push_import_token(&mut tokens, local);
4607 }
4608 }
4609 }
4610
4611 tokens
4612}
4613
4614fn split_import_alias(import_part: &str) -> (&str, &str) {
4615 if let Some(pos) = import_part.find(" as ") {
4616 let original = import_part[..pos].trim();
4617 let local = import_part[pos + 4..].trim();
4618 (original, local)
4619 } else {
4620 let name = import_part.split_whitespace().next().unwrap_or("").trim();
4621 (name, name)
4622 }
4623}
4624
4625fn push_import_token(tokens: &mut Vec<String>, token: &str) {
4626 let token = token.trim();
4627 if !token.is_empty() && token != "*" {
4628 tokens.push(token.to_string());
4629 }
4630}
4631
4632fn extract_references_with_stripped<'a>(
4636 content: &'a str,
4637 own_name: &str,
4638 stripped: &str,
4639 extra_ident_chars: &'static [char],
4640) -> Vec<&'a str> {
4641 extract_references_with_stripped_filtered(
4642 content,
4643 own_name,
4644 stripped,
4645 extra_ident_chars,
4646 |_, _, _| true,
4647 )
4648}
4649
4650fn extract_references_with_stripped_filtered<'a, F>(
4651 content: &'a str,
4652 own_name: &str,
4653 stripped: &str,
4654 extra_ident_chars: &'static [char],
4655 mut include_token: F,
4656) -> Vec<&'a str>
4657where
4658 F: FnMut(usize, usize, usize) -> bool,
4659{
4660 let mut refs = Vec::new();
4661 let mut seen: HashSet<&str> = HashSet::default();
4662 let mut token_start: Option<usize> = None;
4663 let mut line = 1;
4664
4665 for (idx, ch) in content.char_indices() {
4666 if ch.is_alphanumeric() || ch == '_' || extra_ident_chars.contains(&ch) {
4667 if token_start.is_none() {
4668 token_start = Some(idx);
4669 }
4670 continue;
4671 }
4672
4673 if let Some(start) = token_start.take() {
4674 maybe_push_reference_token(
4675 content,
4676 stripped,
4677 start,
4678 idx,
4679 line,
4680 own_name,
4681 &mut seen,
4682 &mut refs,
4683 &mut include_token,
4684 );
4685 }
4686
4687 if ch == '\n' {
4688 line += 1;
4689 }
4690 }
4691
4692 if let Some(start) = token_start {
4693 maybe_push_reference_token(
4694 content,
4695 stripped,
4696 start,
4697 content.len(),
4698 line,
4699 own_name,
4700 &mut seen,
4701 &mut refs,
4702 &mut include_token,
4703 );
4704 }
4705
4706 refs
4707}
4708
4709fn maybe_push_reference_token<'a, F>(
4710 content: &'a str,
4711 stripped: &str,
4712 start: usize,
4713 end: usize,
4714 line: usize,
4715 own_name: &str,
4716 seen: &mut HashSet<&'a str>,
4717 refs: &mut Vec<&'a str>,
4718 include_token: &mut F,
4719) where
4720 F: FnMut(usize, usize, usize) -> bool,
4721{
4722 let word = &content[start..end];
4723 if word.is_empty() || word == own_name {
4724 return;
4725 }
4726 if is_keyword(word) || word.len() < 2 {
4727 return;
4728 }
4729 if word.starts_with(|c: char| c.is_lowercase()) && word.len() < 3 {
4731 return;
4732 }
4733 if word
4735 .chars()
4736 .all(|c| !c.is_alphanumeric() && c != '_' && c != '-')
4737 {
4738 return;
4739 }
4740 if !word.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '-' || c == '*') {
4745 return;
4746 }
4747 if is_common_local_name(word) {
4749 return;
4750 }
4751 if stripped.get(start..end) != Some(word) {
4753 return;
4754 }
4755 if !include_token(line, start, end) {
4756 return;
4757 }
4758 if seen.insert(word) {
4759 refs.push(word);
4760 }
4761}
4762
4763static COMMON_LOCAL_NAMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
4764 [
4765 "result", "results", "data", "config", "value", "values", "item", "items", "input",
4766 "output", "args", "opts", "name", "path", "file", "line", "count", "index", "temp", "prev",
4767 "next", "curr", "current", "node", "left", "right", "root", "head", "tail", "body", "text",
4768 "content", "source", "target", "entry", "error", "errors", "message", "response",
4769 "request", "context", "state", "props", "event", "handler", "callback", "options",
4770 "params", "query", "list", "base", "info", "meta", "kind", "mode", "flag", "size",
4771 "length", "width", "height", "start", "stop", "begin", "done", "found", "status", "code",
4772 ]
4773 .into_iter()
4774 .collect()
4775});
4776
4777fn is_common_local_name(word: &str) -> bool {
4780 COMMON_LOCAL_NAMES.contains(word)
4781}
4782
4783fn infer_ref_type(content: &str, ref_name: &str) -> RefType {
4785 let bytes = content.as_bytes();
4788 let name_bytes = ref_name.as_bytes();
4789 let mut search_start = 0;
4790 while let Some(rel_pos) = content[search_start..].find(ref_name) {
4791 let pos = search_start + rel_pos;
4792 let after = pos + name_bytes.len();
4793 if after < bytes.len() && bytes[after] == b'(' {
4795 let is_boundary = pos == 0 || {
4797 let prev = bytes[pos - 1];
4798 !prev.is_ascii_alphanumeric() && prev != b'_'
4799 };
4800 if is_boundary {
4801 return RefType::Calls;
4802 }
4803 }
4804 search_start = pos + 1;
4806 while search_start < content.len() && !content.is_char_boundary(search_start) {
4807 search_start += 1;
4808 }
4809 }
4810
4811 for line in content.lines() {
4813 let trimmed = line.trim();
4814 if (trimmed.starts_with("import ")
4815 || trimmed.starts_with("use ")
4816 || trimmed.starts_with("from ")
4817 || trimmed.starts_with("require("))
4818 && trimmed.contains(ref_name)
4819 {
4820 return RefType::Imports;
4821 }
4822 }
4823
4824 RefType::TypeRef
4826}
4827
4828static KEYWORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
4829 [
4830 "if",
4832 "else",
4833 "for",
4834 "while",
4835 "do",
4836 "switch",
4837 "case",
4838 "break",
4839 "continue",
4840 "return",
4841 "try",
4842 "catch",
4843 "finally",
4844 "throw",
4845 "new",
4846 "delete",
4847 "typeof",
4848 "instanceof",
4849 "in",
4850 "of",
4851 "true",
4852 "false",
4853 "null",
4854 "undefined",
4855 "void",
4856 "this",
4857 "super",
4858 "class",
4859 "extends",
4860 "implements",
4861 "interface",
4862 "enum",
4863 "const",
4864 "let",
4865 "var",
4866 "function",
4867 "async",
4868 "await",
4869 "yield",
4870 "import",
4871 "export",
4872 "default",
4873 "from",
4874 "as",
4875 "static",
4876 "public",
4877 "private",
4878 "protected",
4879 "abstract",
4880 "final",
4881 "override",
4882 "fn",
4884 "pub",
4885 "mod",
4886 "use",
4887 "struct",
4888 "impl",
4889 "trait",
4890 "where",
4891 "type",
4892 "self",
4893 "Self",
4894 "mut",
4895 "ref",
4896 "match",
4897 "loop",
4898 "move",
4899 "unsafe",
4900 "extern",
4901 "crate",
4902 "dyn",
4903 "def",
4905 "elif",
4906 "except",
4907 "raise",
4908 "with",
4909 "pass",
4910 "lambda",
4911 "nonlocal",
4912 "global",
4913 "assert",
4914 "True",
4915 "False",
4916 "and",
4917 "or",
4918 "not",
4919 "is",
4920 "func",
4922 "package",
4923 "range",
4924 "select",
4925 "chan",
4926 "go",
4927 "defer",
4928 "map",
4929 "make",
4930 "append",
4931 "len",
4932 "cap",
4933 "auto",
4935 "register",
4936 "volatile",
4937 "sizeof",
4938 "typedef",
4939 "template",
4940 "typename",
4941 "namespace",
4942 "virtual",
4943 "inline",
4944 "constexpr",
4945 "nullptr",
4946 "noexcept",
4947 "explicit",
4948 "friend",
4949 "operator",
4950 "using",
4951 "cout",
4952 "endl",
4953 "cerr",
4954 "cin",
4955 "printf",
4956 "scanf",
4957 "malloc",
4958 "free",
4959 "NULL",
4960 "include",
4961 "ifdef",
4962 "ifndef",
4963 "endif",
4964 "define",
4965 "pragma",
4966 "end",
4968 "then",
4969 "elsif",
4970 "unless",
4971 "until",
4972 "begin",
4973 "rescue",
4974 "ensure",
4975 "when",
4976 "require",
4977 "attr_accessor",
4978 "attr_reader",
4979 "attr_writer",
4980 "puts",
4981 "nil",
4982 "module",
4983 "defined",
4984 "internal",
4986 "sealed",
4987 "readonly",
4988 "partial",
4989 "delegate",
4990 "event",
4991 "params",
4992 "out",
4993 "object",
4994 "decimal",
4995 "sbyte",
4996 "ushort",
4997 "uint",
4998 "ulong",
4999 "nint",
5000 "nuint",
5001 "dynamic",
5002 "get",
5003 "set",
5004 "value",
5005 "init",
5006 "record",
5007 "string",
5009 "number",
5010 "boolean",
5011 "int",
5012 "float",
5013 "double",
5014 "bool",
5015 "char",
5016 "byte",
5017 "i8",
5018 "i16",
5019 "i32",
5020 "i64",
5021 "u8",
5022 "u16",
5023 "u32",
5024 "u64",
5025 "f32",
5026 "f64",
5027 "usize",
5028 "isize",
5029 "str",
5030 "String",
5031 "Vec",
5032 "Option",
5033 "Result",
5034 "Box",
5035 "Arc",
5036 "Rc",
5037 "HashMap",
5038 "HashSet",
5039 "Some",
5040 "Ok",
5041 "Err",
5042 ]
5043 .into_iter()
5044 .collect()
5045});
5046
5047fn is_keyword(word: &str) -> bool {
5048 KEYWORDS.contains(word)
5049}
5050
5051#[cfg(test)]
5052mod tests {
5053 use super::*;
5054 use crate::git::types::{FileChange, FileStatus};
5055 use crate::parser::plugins::code::languages::StripStrategy;
5056 use std::io::Write;
5057 use tempfile::TempDir;
5058
5059 fn create_test_repo() -> (TempDir, ParserRegistry) {
5060 let dir = TempDir::new().unwrap();
5061 let registry = crate::parser::plugins::create_default_registry();
5062 (dir, registry)
5063 }
5064
5065 fn write_file(dir: &Path, name: &str, content: &str) {
5066 let path = dir.join(name);
5067 if let Some(parent) = path.parent() {
5068 std::fs::create_dir_all(parent).unwrap();
5069 }
5070 let mut f = std::fs::File::create(path).unwrap();
5071 f.write_all(content.as_bytes()).unwrap();
5072 }
5073
5074 fn dependency_ids(graph: &EntityGraph, entity_id: &str) -> Vec<String> {
5075 let mut ids = graph
5076 .get_dependencies(entity_id)
5077 .into_iter()
5078 .map(|entity| entity.id.clone())
5079 .collect::<Vec<_>>();
5080 ids.sort();
5081 ids
5082 }
5083
5084 fn assert_direct_dependencies_match_full(
5085 root: &Path,
5086 files: &[String],
5087 registry: &ParserRegistry,
5088 entity_id: &str,
5089 ) {
5090 let (full_graph, _) = EntityGraph::build(root, files, registry);
5091 let expected = dependency_ids(&full_graph, entity_id);
5092 let (direct_graph, _) =
5093 EntityGraph::build_direct_dependencies(root, files, registry, |entity| {
5094 entity.id == entity_id
5095 });
5096 let actual = dependency_ids(&direct_graph, entity_id);
5097 assert_eq!(actual, expected);
5098 }
5099
5100 fn graph_json_payload(graph: &EntityGraph) -> serde_json::Value {
5101 let mut entities = graph.entities.values().collect::<Vec<_>>();
5102 entities.sort_by(|a, b| a.id.cmp(&b.id));
5103
5104 let mut edges = graph.edges.iter().collect::<Vec<_>>();
5105 edges.sort_by(|a, b| {
5106 a.from_entity
5107 .cmp(&b.from_entity)
5108 .then_with(|| a.to_entity.cmp(&b.to_entity))
5109 .then_with(|| {
5110 test_ref_type_sort_key(&a.ref_type).cmp(&test_ref_type_sort_key(&b.ref_type))
5111 })
5112 });
5113
5114 serde_json::json!({
5115 "entities": entities,
5116 "edges": edges,
5117 "stats": {
5118 "entityCount": graph.entities.len(),
5119 "edgeCount": graph.edges.len(),
5120 },
5121 })
5122 }
5123
5124 fn test_ref_type_sort_key(ref_type: &RefType) -> u8 {
5125 match ref_type {
5126 RefType::Calls => 0,
5127 RefType::Imports => 1,
5128 RefType::TypeRef => 2,
5129 }
5130 }
5131
5132 fn deep_typescript(depth: usize) -> String {
5133 let mut content = String::from("class L0 {\n");
5134 for i in 1..depth {
5135 content.push_str(&" ".repeat(i));
5136 content.push_str(&format!("L{i} = class {{\n"));
5137 }
5138 content.push_str(&" ".repeat(depth));
5139 content.push_str("method() { return 1; }\n");
5140 for i in (1..depth).rev() {
5141 content.push_str(&" ".repeat(i));
5142 content.push_str("};\n");
5143 }
5144 content.push_str("}\n");
5145 content
5146 }
5147
5148 #[test]
5149 fn test_file_reference_index_matches_reference_helpers() {
5150 let content = "\
5151import { Foo } from './foo';
5152class Runner {
5153 run() { return this.validate(Foo); }
5154 validate(input) { return input; }
5155}
5156";
5157 let index = FileReferenceIndex::from_content(content, &[]);
5158 let refs = index.refs_with_types_in_ranges(&[(1, 5)], "run");
5159 assert!(refs.iter().any(|(word, _)| *word == "Foo"));
5160 assert!(refs.iter().any(|(word, _)| *word == "Runner"));
5161 assert!(!refs.iter().any(|(word, _)| *word == "input"));
5162
5163 let dot_chains = index.dot_chains_in_ranges(&[(1, 5)]);
5164 assert!(dot_chains.contains(&("this", "validate")));
5165 assert!(refs
5166 .iter()
5167 .any(|(word, ref_type)| *word == "Foo" && *ref_type == RefType::Imports));
5168 assert!(refs
5169 .iter()
5170 .any(|(word, ref_type)| *word == "validate" && *ref_type == RefType::Calls));
5171 }
5172
5173 #[test]
5174 fn test_js_ts_import_token_scan_matches_supported_import_forms() {
5175 let content = "\
5176import type { X as TypeX } from './stale';
5177import DefaultThing, { X as Y, Z } from './stale';
5178import * as ns$ from './stale';
5179export { default as PublicDefault, X as PublicX } from './stale';
5180";
5181
5182 let mut tokens = content_import_tokens_for_file("consumer.ts", content, "stale.ts");
5183 tokens.sort_unstable();
5184 tokens.dedup();
5185
5186 for expected in [
5187 "X",
5188 "TypeX",
5189 "DefaultThing",
5190 "Y",
5191 "Z",
5192 "ns$",
5193 "default",
5194 "PublicDefault",
5195 "PublicX",
5196 ] {
5197 assert!(
5198 tokens.iter().any(|token| token == expected),
5199 "missing token {expected}; tokens: {tokens:?}"
5200 );
5201 }
5202 }
5203
5204 #[test]
5205 fn test_direct_reference_ranges_skip_nested_child_entities() {
5206 let parent = SemanticEntity {
5207 id: "parent".to_string(),
5208 file_path: "sample.ts".to_string(),
5209 entity_type: "function".to_string(),
5210 name: "outer".to_string(),
5211 parent_id: None,
5212 content: String::new(),
5213 content_hash: String::new(),
5214 structural_hash: None,
5215 start_line: 1,
5216 end_line: 7,
5217 metadata: None,
5218 };
5219 let mut child_line_ranges = HashMap::default();
5220 child_line_ranges.insert("parent".to_string(), vec![(3, 5)]);
5221
5222 let ranges = direct_reference_line_ranges(&parent, parent.end_line, &child_line_ranges);
5223 assert_eq!(ranges, vec![(1, 2), (6, 7)]);
5224
5225 let content = "\
5226function outer() {
5227 setup();
5228 function inner() {
5229 nested();
5230 }
5231 finish();
5232}
5233";
5234 let index = FileReferenceIndex::from_content(content, &[]);
5235 let refs = index.refs_with_types_in_ranges(&ranges, "outer");
5236
5237 assert!(refs.iter().any(|(word, _)| *word == "setup"));
5238 assert!(refs.iter().any(|(word, _)| *word == "finish"));
5239 assert!(!refs.iter().any(|(word, _)| *word == "nested"));
5240 }
5241
5242 #[test]
5243 fn test_deep_nested_typescript_graph_builds() {
5244 let (dir, registry) = create_test_repo();
5245 let root = dir.path();
5246
5247 let depth = 160;
5248 write_file(root, "deep.ts", &deep_typescript(depth));
5249
5250 let (graph, entities) = EntityGraph::build(root, &["deep.ts".into()], ®istry);
5251
5252 assert!(graph.entities.contains_key("deep.ts::class::L0"));
5253 assert!(entities.iter().any(|e| e.name == "method"));
5254 assert_eq!(entities.len(), depth + 1);
5255 }
5256
5257 #[test]
5258 fn test_chunked_scope_resolution_keeps_cross_chunk_import_edges() {
5259 let (dir, registry) = create_test_repo();
5260 let root = dir.path();
5261
5262 let mut files = Vec::new();
5263 for index in 0..10 {
5264 let file_name = format!("file_{index}.ts");
5265 let content = if index == 0 {
5266 "export function target() { return 1; }\n".to_string()
5267 } else if index == 9 {
5268 "import { target } from './file_0';\nexport function caller() { return target(); }\n"
5269 .to_string()
5270 } else {
5271 format!("export function filler_{index}() {{ return {index}; }}\n")
5272 };
5273 write_file(root, &file_name, &content);
5274 files.push(file_name);
5275 }
5276
5277 let (graph, _) = EntityGraph::build(root, &files, ®istry);
5278 let caller_id = graph
5279 .entities
5280 .iter()
5281 .find(|(_, entity)| entity.name == "caller")
5282 .map(|(id, _)| id.clone())
5283 .expect("caller entity should exist");
5284 let deps = graph.get_dependencies(&caller_id);
5285
5286 assert!(
5287 deps.iter().any(|dep| dep.name == "target"),
5288 "caller should resolve imported target across scope chunks. Deps: {:?}",
5289 deps.iter().map(|dep| &dep.name).collect::<Vec<_>>()
5290 );
5291 }
5292
5293 #[test]
5294 fn test_ts_class_extends_type_ref_survives_scope_fallback_bound() {
5295 let (dir, registry) = create_test_repo();
5296 let root = dir.path();
5297
5298 write_file(
5299 root,
5300 "types.ts",
5301 "\
5302class Base {}
5303class Child extends Base {
5304 run() { return 1; }
5305}
5306",
5307 );
5308
5309 let (graph, _) = EntityGraph::build(root, &["types.ts".into()], ®istry);
5310
5311 assert!(
5312 graph.edges.iter().any(|edge| {
5313 edge.from_entity.contains("Child")
5314 && graph
5315 .entities
5316 .get(&edge.to_entity)
5317 .map_or(false, |e| e.name == "Base")
5318 }),
5319 "Child should keep a type-ref edge to Base. Edges: {:?}",
5320 graph.edges
5321 );
5322 }
5323
5324 #[test]
5325 fn test_multiline_block_comment_preserves_reference_line_index() {
5326 let (dir, registry) = create_test_repo();
5327 let root = dir.path();
5328
5329 write_file(
5330 root,
5331 "calls.c",
5332 "\
5333/*
5334 multiline
5335 comment
5336*/
5337int helper() { return 1; }
5338int caller() { return helper(); }
5339",
5340 );
5341
5342 let (graph, _) = EntityGraph::build(root, &["calls.c".into()], ®istry);
5343
5344 let caller_id = graph
5345 .entities
5346 .keys()
5347 .find(|id| id.contains("caller"))
5348 .expect("caller entity should exist");
5349 let deps = graph.get_dependencies(caller_id);
5350 assert!(
5351 deps.iter().any(|dep| dep.name == "helper"),
5352 "caller should depend on helper after a multiline block comment. Deps: {:?}",
5353 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
5354 );
5355 }
5356
5357 #[test]
5358 fn test_strip_comments_and_strings_preserves_newlines_and_utf8() {
5359 let content = "\
5360const value = \"é\\
5361still string\";
5362const done = call();
5363/* unterminated block
5364comment with Helper
5365";
5366
5367 let stripped = strip_comments_and_strings(content);
5368 let newline_count = |text: &str| text.bytes().filter(|byte| *byte == b'\n').count();
5369
5370 assert_eq!(newline_count(&stripped), newline_count(content));
5371 assert!(stripped.contains("const done = call();"));
5372 assert!(!stripped.contains("still string"));
5373 assert!(!stripped.contains("Helper"));
5374
5375 let trailing_escape = strip_comments_and_strings("const value = \"unterminated\\");
5376 assert_eq!(newline_count(&trailing_escape), 0);
5377
5378 let triple = strip_comments_and_strings("'''doc\nwith Helper");
5379 assert_eq!(newline_count(&triple), 1);
5380 assert!(!triple.contains("Helper"));
5381 }
5382
5383 #[test]
5384 fn test_incremental_add_file() {
5385 let (dir, registry) = create_test_repo();
5386 let root = dir.path();
5387
5388 write_file(root, "a.ts", "export function foo() { return bar(); }\n");
5390 write_file(root, "b.ts", "export function bar() { return 1; }\n");
5391
5392 let (mut graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5393 assert_eq!(graph.entities.len(), 2);
5394
5395 write_file(root, "c.ts", "export function baz() { return foo(); }\n");
5397 graph.update_from_changes(
5398 &[FileChange {
5399 file_path: "c.ts".into(),
5400 status: FileStatus::Added,
5401 old_file_path: None,
5402 before_content: None,
5403 after_content: None, }],
5405 root,
5406 ®istry,
5407 );
5408
5409 assert_eq!(graph.entities.len(), 3);
5410 assert!(graph.entities.contains_key("c.ts::function::baz"));
5411 let baz_deps = graph.get_dependencies("c.ts::function::baz");
5413 assert!(
5414 baz_deps.iter().any(|d| d.name == "foo"),
5415 "baz should depend on foo. Deps: {:?}",
5416 baz_deps.iter().map(|d| &d.name).collect::<Vec<_>>()
5417 );
5418 }
5419
5420 #[test]
5421 fn test_incremental_delete_file() {
5422 let (dir, registry) = create_test_repo();
5423 let root = dir.path();
5424
5425 write_file(root, "a.ts", "export function foo() { return bar(); }\n");
5426 write_file(root, "b.ts", "export function bar() { return 1; }\n");
5427
5428 let (mut graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5429 assert_eq!(graph.entities.len(), 2);
5430
5431 graph.update_from_changes(
5433 &[FileChange {
5434 file_path: "b.ts".into(),
5435 status: FileStatus::Deleted,
5436 old_file_path: None,
5437 before_content: None,
5438 after_content: None,
5439 }],
5440 root,
5441 ®istry,
5442 );
5443
5444 assert_eq!(graph.entities.len(), 1);
5445 assert!(!graph.entities.contains_key("b.ts::function::bar"));
5446 let foo_deps = graph.get_dependencies("a.ts::function::foo");
5448 assert!(
5449 foo_deps.is_empty(),
5450 "foo's deps should be empty after bar deleted. Deps: {:?}",
5451 foo_deps.iter().map(|d| &d.name).collect::<Vec<_>>()
5452 );
5453 }
5454
5455 #[test]
5456 fn test_incremental_modify_file() {
5457 let (dir, registry) = create_test_repo();
5458 let root = dir.path();
5459
5460 write_file(root, "a.ts", "export function foo() { return bar(); }\n");
5461 write_file(
5462 root,
5463 "b.ts",
5464 "export function bar() { return 1; }\nexport function baz() { return 2; }\n",
5465 );
5466
5467 let (mut graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5468 assert_eq!(graph.entities.len(), 3);
5469
5470 write_file(root, "a.ts", "export function foo() { return baz(); }\n");
5472 graph.update_from_changes(
5473 &[FileChange {
5474 file_path: "a.ts".into(),
5475 status: FileStatus::Modified,
5476 old_file_path: None,
5477 before_content: None,
5478 after_content: None,
5479 }],
5480 root,
5481 ®istry,
5482 );
5483
5484 assert_eq!(graph.entities.len(), 3);
5485 let foo_deps = graph.get_dependencies("a.ts::function::foo");
5487 let dep_names: Vec<&str> = foo_deps.iter().map(|d| d.name.as_str()).collect();
5488 assert!(
5489 dep_names.contains(&"baz"),
5490 "foo should depend on baz after modification. Deps: {:?}",
5491 dep_names
5492 );
5493 assert!(
5494 !dep_names.contains(&"bar"),
5495 "foo should no longer depend on bar. Deps: {:?}",
5496 dep_names
5497 );
5498 }
5499
5500 #[test]
5501 fn test_incremental_stale_target_file_re_resolves_clean_caller() {
5502 let (dir, registry) = create_test_repo();
5503 let root = dir.path();
5504
5505 write_file(root, "a.py", "def use_it():\n return helper()\n");
5506 write_file(root, "b.py", "def helper():\n return 1\n");
5507
5508 let (cached_graph, cached_entities) =
5509 EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
5510 assert!(
5511 cached_graph
5512 .get_dependents("b.py::function::helper")
5513 .iter()
5514 .any(|entity| entity.id == "a.py::function::use_it"),
5515 "initial graph should include use_it -> helper"
5516 );
5517
5518 write_file(
5519 root,
5520 "b.py",
5521 "def helper():\n return 1\n\n\ndef unrelated():\n return 42\n",
5522 );
5523
5524 let cached_clean_entities = cached_entities
5525 .iter()
5526 .filter(|entity| entity.file_path != "b.py")
5527 .cloned()
5528 .collect();
5529 let cached_stale_entities = cached_entities
5530 .into_iter()
5531 .filter(|entity| entity.file_path == "b.py")
5532 .collect();
5533
5534 let (graph, _) = EntityGraph::build_incremental(
5535 root,
5536 &["b.py".into()],
5537 &["a.py".into(), "b.py".into()],
5538 cached_clean_entities,
5539 cached_graph.edges,
5540 cached_stale_entities,
5541 ®istry,
5542 );
5543 let (fresh_graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
5544
5545 let mut helper_dependents = graph
5546 .get_dependents("b.py::function::helper")
5547 .iter()
5548 .map(|entity| entity.id.as_str())
5549 .collect::<Vec<_>>();
5550 helper_dependents.sort_unstable();
5551 let mut fresh_dependents = fresh_graph
5552 .get_dependents("b.py::function::helper")
5553 .iter()
5554 .map(|entity| entity.id.as_str())
5555 .collect::<Vec<_>>();
5556 fresh_dependents.sort_unstable();
5557 assert_eq!(
5558 helper_dependents, fresh_dependents,
5559 "incremental graph should match fresh resolution"
5560 );
5561 assert!(
5562 helper_dependents
5563 .iter()
5564 .any(|entity_id| *entity_id == "a.py::function::use_it"),
5565 "clean caller should still depend on content-clean helper. Dependents: {:?}",
5566 helper_dependents
5567 );
5568 }
5569
5570 #[test]
5571 fn test_incremental_added_stale_target_re_resolves_clean_reference() {
5572 let (dir, registry) = create_test_repo();
5573 let root = dir.path();
5574
5575 write_file(root, "a.py", "def use_it():\n return helper()\n");
5576 write_file(root, "b.py", "def other():\n return 1\n");
5577
5578 let (cached_graph, cached_entities) =
5579 EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
5580 assert!(
5581 !cached_graph
5582 .get_dependencies("a.py::function::use_it")
5583 .iter()
5584 .any(|entity| entity.name == "helper"),
5585 "initial graph should not resolve helper"
5586 );
5587
5588 write_file(
5589 root,
5590 "b.py",
5591 "def other():\n return 1\n\n\ndef helper():\n return 42\n",
5592 );
5593
5594 let cached_clean_entities = cached_entities
5595 .iter()
5596 .filter(|entity| entity.file_path != "b.py")
5597 .cloned()
5598 .collect();
5599 let cached_stale_entities = cached_entities
5600 .into_iter()
5601 .filter(|entity| entity.file_path == "b.py")
5602 .collect();
5603
5604 let (incremental_graph, _) = EntityGraph::build_incremental(
5605 root,
5606 &["b.py".into()],
5607 &["a.py".into(), "b.py".into()],
5608 cached_clean_entities,
5609 cached_graph.edges,
5610 cached_stale_entities,
5611 ®istry,
5612 );
5613 let (fresh_graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
5614
5615 let mut incremental_dependents = incremental_graph
5616 .get_dependents("b.py::function::helper")
5617 .iter()
5618 .map(|entity| entity.id.as_str())
5619 .collect::<Vec<_>>();
5620 incremental_dependents.sort_unstable();
5621 let mut fresh_dependents = fresh_graph
5622 .get_dependents("b.py::function::helper")
5623 .iter()
5624 .map(|entity| entity.id.as_str())
5625 .collect::<Vec<_>>();
5626 fresh_dependents.sort_unstable();
5627 assert_eq!(
5628 incremental_dependents, fresh_dependents,
5629 "incremental graph should match fresh resolution"
5630 );
5631 assert!(
5632 incremental_dependents
5633 .iter()
5634 .any(|entity_id| *entity_id == "a.py::function::use_it"),
5635 "clean caller should resolve to added helper. Dependents: {:?}",
5636 incremental_dependents
5637 );
5638 }
5639
5640 #[test]
5641 fn test_incremental_added_stale_target_re_resolves_aliased_clean_reference() {
5642 let (dir, registry) = create_test_repo();
5643 let root = dir.path();
5644
5645 write_file(
5646 root,
5647 "a.ts",
5648 "import { helper as h } from './b';\n\nexport function useIt() { return h(); }\n",
5649 );
5650 write_file(root, "b.ts", "export function other() { return 1; }\n");
5651
5652 let (cached_graph, cached_entities) =
5653 EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5654 assert!(
5655 !cached_graph
5656 .get_dependencies("a.ts::function::useIt")
5657 .iter()
5658 .any(|entity| entity.name == "helper"),
5659 "initial graph should not resolve aliased helper"
5660 );
5661
5662 write_file(
5663 root,
5664 "b.ts",
5665 "export function other() { return 1; }\n\nexport function helper() { return 42; }\n",
5666 );
5667
5668 let cached_clean_entities = cached_entities
5669 .iter()
5670 .filter(|entity| entity.file_path != "b.ts")
5671 .cloned()
5672 .collect();
5673 let cached_stale_entities = cached_entities
5674 .into_iter()
5675 .filter(|entity| entity.file_path == "b.ts")
5676 .collect();
5677
5678 let (incremental_graph, _) = EntityGraph::build_incremental(
5679 root,
5680 &["b.ts".into()],
5681 &["a.ts".into(), "b.ts".into()],
5682 cached_clean_entities,
5683 cached_graph.edges,
5684 cached_stale_entities,
5685 ®istry,
5686 );
5687 let (fresh_graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5688
5689 let mut incremental_dependents = incremental_graph
5690 .get_dependents("b.ts::function::helper")
5691 .iter()
5692 .map(|entity| entity.id.as_str())
5693 .collect::<Vec<_>>();
5694 incremental_dependents.sort_unstable();
5695 let mut fresh_dependents = fresh_graph
5696 .get_dependents("b.ts::function::helper")
5697 .iter()
5698 .map(|entity| entity.id.as_str())
5699 .collect::<Vec<_>>();
5700 fresh_dependents.sort_unstable();
5701 assert_eq!(
5702 incremental_dependents, fresh_dependents,
5703 "incremental graph should match fresh alias resolution"
5704 );
5705 assert!(
5706 incremental_dependents
5707 .iter()
5708 .any(|entity_id| *entity_id == "a.ts::function::useIt"),
5709 "aliased clean caller should resolve to added helper. Dependents: {:?}",
5710 incremental_dependents
5711 );
5712 }
5713
5714 #[test]
5715 fn test_incremental_added_stale_target_re_resolves_python_alias() {
5716 let (dir, registry) = create_test_repo();
5717 let root = dir.path();
5718
5719 write_file(
5720 root,
5721 "a.py",
5722 "from b import helper as h\n\ndef use_it():\n return h()\n",
5723 );
5724 write_file(root, "b.py", "def other():\n return 1\n");
5725
5726 let (cached_graph, cached_entities) =
5727 EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
5728 assert!(
5729 !cached_graph
5730 .get_dependencies("a.py::function::use_it")
5731 .iter()
5732 .any(|entity| entity.name == "helper"),
5733 "initial graph should not resolve aliased helper"
5734 );
5735
5736 write_file(
5737 root,
5738 "b.py",
5739 "def other():\n return 1\n\n\ndef helper():\n return 42\n",
5740 );
5741
5742 let cached_clean_entities = cached_entities
5743 .iter()
5744 .filter(|entity| entity.file_path != "b.py")
5745 .cloned()
5746 .collect();
5747 let cached_stale_entities = cached_entities
5748 .into_iter()
5749 .filter(|entity| entity.file_path == "b.py")
5750 .collect();
5751
5752 let (incremental_graph, _) = EntityGraph::build_incremental(
5753 root,
5754 &["b.py".into()],
5755 &["a.py".into(), "b.py".into()],
5756 cached_clean_entities,
5757 cached_graph.edges,
5758 cached_stale_entities,
5759 ®istry,
5760 );
5761 let (fresh_graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
5762
5763 let mut incremental_dependents = incremental_graph
5764 .get_dependents("b.py::function::helper")
5765 .iter()
5766 .map(|entity| entity.id.as_str())
5767 .collect::<Vec<_>>();
5768 incremental_dependents.sort_unstable();
5769 let mut fresh_dependents = fresh_graph
5770 .get_dependents("b.py::function::helper")
5771 .iter()
5772 .map(|entity| entity.id.as_str())
5773 .collect::<Vec<_>>();
5774 fresh_dependents.sort_unstable();
5775 assert_eq!(
5776 incremental_dependents, fresh_dependents,
5777 "incremental graph should match fresh Python alias resolution"
5778 );
5779 assert!(
5780 incremental_dependents
5781 .iter()
5782 .any(|entity_id| *entity_id == "a.py::function::use_it"),
5783 "aliased clean caller should resolve to added helper. Dependents: {:?}",
5784 incremental_dependents
5785 );
5786 }
5787
5788 #[test]
5789 fn test_incremental_added_stale_target_re_resolves_namespace_short_reference() {
5790 let (dir, registry) = create_test_repo();
5791 let root = dir.path();
5792
5793 write_file(
5794 root,
5795 "a.ts",
5796 "import * as b from './b';\n\nexport function useIt() { return b.go(); }\n",
5797 );
5798 write_file(root, "b.ts", "export function other() { return 1; }\n");
5799
5800 let (cached_graph, cached_entities) =
5801 EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5802 assert!(
5803 !cached_graph
5804 .get_dependencies("a.ts::function::useIt")
5805 .iter()
5806 .any(|entity| entity.name == "go"),
5807 "initial graph should not resolve namespace go"
5808 );
5809
5810 write_file(
5811 root,
5812 "b.ts",
5813 "export function other() { return 1; }\n\nexport function go() { return 42; }\n",
5814 );
5815
5816 let cached_clean_entities = cached_entities
5817 .iter()
5818 .filter(|entity| entity.file_path != "b.ts")
5819 .cloned()
5820 .collect();
5821 let cached_stale_entities = cached_entities
5822 .into_iter()
5823 .filter(|entity| entity.file_path == "b.ts")
5824 .collect();
5825
5826 let (incremental_graph, _) = EntityGraph::build_incremental(
5827 root,
5828 &["b.ts".into()],
5829 &["a.ts".into(), "b.ts".into()],
5830 cached_clean_entities,
5831 cached_graph.edges,
5832 cached_stale_entities,
5833 ®istry,
5834 );
5835 let (fresh_graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], ®istry);
5836
5837 let mut incremental_dependents = incremental_graph
5838 .get_dependents("b.ts::function::go")
5839 .iter()
5840 .map(|entity| entity.id.as_str())
5841 .collect::<Vec<_>>();
5842 incremental_dependents.sort_unstable();
5843 let mut fresh_dependents = fresh_graph
5844 .get_dependents("b.ts::function::go")
5845 .iter()
5846 .map(|entity| entity.id.as_str())
5847 .collect::<Vec<_>>();
5848 fresh_dependents.sort_unstable();
5849 assert_eq!(
5850 incremental_dependents, fresh_dependents,
5851 "incremental graph should match fresh namespace resolution"
5852 );
5853 assert!(
5854 incremental_dependents
5855 .iter()
5856 .any(|entity_id| *entity_id == "a.ts::function::useIt"),
5857 "namespace clean caller should resolve to added go. Dependents: {:?}",
5858 incremental_dependents
5859 );
5860 }
5861
5862 #[test]
5863 fn test_incremental_stale_default_re_export_re_resolves_clean_barrel() {
5864 let (dir, registry) = create_test_repo();
5865 let root = dir.path();
5866
5867 write_file(
5868 root,
5869 "a.ts",
5870 "export default function targetA() { return 1; }\n",
5871 );
5872 write_file(
5873 root,
5874 "b.ts",
5875 "export default function targetB() { return 2; }\n",
5876 );
5877 write_file(root, "stale.ts", "export { default } from './a';\n");
5878 write_file(
5879 root,
5880 "barrel.ts",
5881 "export { default as publicTarget } from './stale';\n",
5882 );
5883
5884 let all_files = vec![
5885 "a.ts".to_string(),
5886 "b.ts".to_string(),
5887 "stale.ts".to_string(),
5888 "barrel.ts".to_string(),
5889 ];
5890 let (cached_graph, cached_entities) = EntityGraph::build(root, &all_files, ®istry);
5891 let initial_deps = cached_graph.get_dependencies("barrel.ts::export::publicTarget");
5892 assert!(
5893 initial_deps
5894 .iter()
5895 .any(|entity| entity.file_path == "a.ts" && entity.name == "targetA"),
5896 "initial barrel export should resolve through stale.ts to a.ts. Deps: {:?}",
5897 initial_deps
5898 .iter()
5899 .map(|entity| (&entity.file_path, &entity.name))
5900 .collect::<Vec<_>>()
5901 );
5902
5903 write_file(root, "stale.ts", "export { default } from './b';\n");
5904
5905 let cached_clean_entities = cached_entities
5906 .iter()
5907 .filter(|entity| entity.file_path != "stale.ts")
5908 .cloned()
5909 .collect();
5910 let cached_stale_entities = cached_entities
5911 .into_iter()
5912 .filter(|entity| entity.file_path == "stale.ts")
5913 .collect();
5914
5915 let (incremental_graph, _) = EntityGraph::build_incremental(
5916 root,
5917 &["stale.ts".into()],
5918 &all_files,
5919 cached_clean_entities,
5920 cached_graph.edges,
5921 cached_stale_entities,
5922 ®istry,
5923 );
5924 let (fresh_graph, _) = EntityGraph::build(root, &all_files, ®istry);
5925
5926 let mut incremental_deps = incremental_graph
5927 .get_dependencies("barrel.ts::export::publicTarget")
5928 .iter()
5929 .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
5930 .collect::<Vec<_>>();
5931 incremental_deps.sort_unstable();
5932 let mut fresh_deps = fresh_graph
5933 .get_dependencies("barrel.ts::export::publicTarget")
5934 .iter()
5935 .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
5936 .collect::<Vec<_>>();
5937 fresh_deps.sort_unstable();
5938 assert_eq!(
5939 incremental_deps, fresh_deps,
5940 "incremental graph should match fresh re-export retargeting"
5941 );
5942 assert!(
5943 incremental_deps
5944 .iter()
5945 .any(|(file_path, name)| *file_path == "b.ts" && *name == "targetB"),
5946 "clean barrel export should retarget to b.ts. Deps: {:?}",
5947 incremental_deps
5948 );
5949 }
5950
5951 #[test]
5952 fn test_incremental_import_candidates_re_resolve_clean_barrel() {
5953 let (dir, registry) = create_test_repo();
5954 let root = dir.path();
5955
5956 write_file(
5957 root,
5958 "a.ts",
5959 "export default function targetA() { return 1; }\n",
5960 );
5961 write_file(
5962 root,
5963 "b.ts",
5964 "export default function targetB() { return 2; }\n",
5965 );
5966 write_file(root, "stale.ts", "export { default } from './a';\n");
5967 write_file(
5968 root,
5969 "barrel.ts",
5970 "export { default as publicTarget } from './stale';\n",
5971 );
5972
5973 let all_files = vec![
5974 "a.ts".to_string(),
5975 "b.ts".to_string(),
5976 "stale.ts".to_string(),
5977 "barrel.ts".to_string(),
5978 ];
5979 let (cached_graph, cached_entities) = EntityGraph::build(root, &all_files, ®istry);
5980
5981 write_file(root, "stale.ts", "export { default } from './b';\n");
5982
5983 let cached_clean_entities = cached_entities
5984 .iter()
5985 .filter(|entity| entity.file_path != "stale.ts")
5986 .cloned()
5987 .collect();
5988 let cached_stale_entities = cached_entities
5989 .into_iter()
5990 .filter(|entity| entity.file_path == "stale.ts")
5991 .collect();
5992 let cached_importing_stale_files = vec!["barrel.ts".to_string()];
5993
5994 let (incremental_graph, _, _) =
5995 EntityGraph::build_incremental_with_metadata_and_import_candidates(
5996 root,
5997 &["stale.ts".into()],
5998 &all_files,
5999 cached_clean_entities,
6000 cached_graph.edges,
6001 cached_stale_entities,
6002 Some(&cached_importing_stale_files),
6003 ®istry,
6004 );
6005 let (fresh_graph, _) = EntityGraph::build(root, &all_files, ®istry);
6006
6007 let mut incremental_deps = incremental_graph
6008 .get_dependencies("barrel.ts::export::publicTarget")
6009 .iter()
6010 .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
6011 .collect::<Vec<_>>();
6012 incremental_deps.sort_unstable();
6013 let mut fresh_deps = fresh_graph
6014 .get_dependencies("barrel.ts::export::publicTarget")
6015 .iter()
6016 .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
6017 .collect::<Vec<_>>();
6018 fresh_deps.sort_unstable();
6019
6020 assert_eq!(incremental_deps, fresh_deps);
6021 assert!(
6022 incremental_deps
6023 .iter()
6024 .any(|(file_path, name)| *file_path == "b.ts" && *name == "targetB"),
6025 "candidate-aware incremental rebuild should retarget the clean barrel export. Deps: {:?}",
6026 incremental_deps
6027 );
6028 }
6029
6030 #[test]
6031 fn test_incremental_swift_overload_uses_clean_callee_signatures() {
6032 let (dir, registry) = create_test_repo();
6033 let root = dir.path();
6034
6035 write_file(
6036 root,
6037 "Callee.swift",
6038 r#"func load(id: Int) -> String { return "id" }
6039
6040func load(name: String) -> String { return "name" }
6041"#,
6042 );
6043 write_file(
6044 root,
6045 "Caller.swift",
6046 r#"func call() -> String { return load(id: 1) }
6047"#,
6048 );
6049
6050 let file_paths = vec!["Caller.swift".to_string(), "Callee.swift".to_string()];
6051 let (initial_graph, initial_entities) = EntityGraph::build(root, &file_paths, ®istry);
6052
6053 write_file(
6054 root,
6055 "Caller.swift",
6056 r#"func call() -> String { return load(name: "x") }
6057"#,
6058 );
6059
6060 let stale_file_cached_entities: Vec<SemanticEntity> = initial_entities
6061 .iter()
6062 .filter(|entity| entity.file_path == "Caller.swift")
6063 .cloned()
6064 .collect();
6065 let cached_entities: Vec<SemanticEntity> = initial_entities
6066 .into_iter()
6067 .filter(|entity| entity.file_path != "Caller.swift")
6068 .collect();
6069
6070 let (graph, _) = EntityGraph::build_incremental(
6071 root,
6072 &["Caller.swift".to_string()],
6073 &file_paths,
6074 cached_entities,
6075 initial_graph.edges,
6076 stale_file_cached_entities,
6077 ®istry,
6078 );
6079
6080 let has_edge = |from: &str, to: &str| {
6081 graph.edges.iter().any(|edge| {
6082 edge.from_entity == from && edge.to_entity == to && edge.ref_type == RefType::Calls
6083 })
6084 };
6085
6086 assert!(
6087 has_edge(
6088 "Caller.swift::function::call",
6089 "Callee.swift::function::load@L3"
6090 ),
6091 "incremental caller should resolve load(name:) using clean callee signatures"
6092 );
6093 assert!(
6094 !has_edge(
6095 "Caller.swift::function::call",
6096 "Callee.swift::function::load@L1"
6097 ),
6098 "incremental caller should not fall back to load(id:)"
6099 );
6100 }
6101
6102 #[test]
6103 fn test_incremental_with_content() {
6104 let (dir, registry) = create_test_repo();
6105 let root = dir.path();
6106
6107 write_file(root, "a.ts", "export function foo() { return 1; }\n");
6108 let (mut graph, _) = EntityGraph::build(root, &["a.ts".into()], ®istry);
6109 assert_eq!(graph.entities.len(), 1);
6110
6111 graph.update_from_changes(
6113 &[FileChange {
6114 file_path: "b.ts".into(),
6115 status: FileStatus::Added,
6116 old_file_path: None,
6117 before_content: None,
6118 after_content: Some("export function bar() { return foo(); }\n".into()),
6119 }],
6120 root,
6121 ®istry,
6122 );
6123
6124 assert_eq!(graph.entities.len(), 2);
6125 let bar_deps = graph.get_dependencies("b.ts::function::bar");
6126 assert!(bar_deps.iter().any(|d| d.name == "foo"));
6127 }
6128
6129 #[cfg(feature = "lang-go")]
6130 #[test]
6131 fn test_go_method_parent_resolves_across_files_in_graph() {
6132 let (dir, registry) = create_test_repo();
6133 let root = dir.path();
6134
6135 write_file(root, "models.go", "package demo\n\ntype Service struct{}\n");
6136 write_file(
6137 root,
6138 "methods.go",
6139 "package demo\n\nfunc (s *Service) Run() {}\n",
6140 );
6141
6142 let (graph, entities) =
6143 EntityGraph::build(root, &["models.go".into(), "methods.go".into()], ®istry);
6144 let service = graph
6145 .entities
6146 .get("models.go::type::Service")
6147 .expect("Service type should be in the graph");
6148 let run = entities
6149 .iter()
6150 .find(|e| e.name == "Run" && e.file_path == "methods.go")
6151 .expect("Run method should be extracted");
6152
6153 assert_eq!(run.parent_id.as_deref(), Some(service.id.as_str()));
6154 assert!(graph.entities.contains_key("models.go::type::Service::Run"));
6155 }
6156
6157 #[cfg(feature = "lang-go")]
6158 #[test]
6159 fn test_incremental_go_parent_repair_handles_clean_cached_method() {
6160 let (dir, registry) = create_test_repo();
6161 let root = dir.path();
6162 let models = "package demo\n\ntype Service struct{}\n";
6163 let methods = "package demo\n\nfunc (s *Service) Run() {}\n";
6164
6165 write_file(root, "models.go", models);
6166 write_file(root, "methods.go", methods);
6167
6168 let cached_entities = registry.extract_entities("methods.go", methods);
6169 let cached_run = cached_entities
6170 .iter()
6171 .find(|e| e.name == "Run")
6172 .expect("cached Run method should be extracted");
6173 assert_eq!(
6174 cached_run.parent_id.as_deref(),
6175 Some("methods.go::type::Service")
6176 );
6177
6178 let stale_file_cached_entities = registry.extract_entities("models.go", models);
6179 let (graph, entities, metadata) = EntityGraph::build_incremental_with_metadata(
6180 root,
6181 &["models.go".into()],
6182 &["models.go".into(), "methods.go".into()],
6183 cached_entities,
6184 vec![],
6185 stale_file_cached_entities,
6186 ®istry,
6187 );
6188 let service = graph
6189 .entities
6190 .get("models.go::type::Service")
6191 .expect("Service type should be in the graph");
6192 let run = entities
6193 .iter()
6194 .find(|e| e.name == "Run" && e.file_path == "methods.go")
6195 .expect("Run method should be retained from clean cache");
6196
6197 assert_eq!(run.parent_id.as_deref(), Some(service.id.as_str()));
6198 assert!(graph.entities.contains_key("models.go::type::Service::Run"));
6199 assert!(!graph
6200 .entities
6201 .contains_key("methods.go::type::Service::Run"));
6202 assert!(metadata.repaired_clean_entity_ids);
6203 }
6204
6205 #[cfg(feature = "lang-go")]
6206 #[test]
6207 fn test_go_receiver_child_range_does_not_hide_parent_file_edges() {
6208 let (dir, registry) = create_test_repo();
6209 let root = dir.path();
6210
6211 write_file(
6212 root,
6213 "models.go",
6214 "package demo\n\
6215 type Dependency struct{}\n\
6216 type Service struct { Dependency }\n",
6217 );
6218 write_file(
6219 root,
6220 "methods.go",
6221 "package demo\n\n\
6222 func (s *Service) Run() {}\n",
6223 );
6224
6225 let (graph, _) =
6226 EntityGraph::build(root, &["models.go".into(), "methods.go".into()], ®istry);
6227
6228 assert!(
6229 graph.edges.iter().any(|edge| {
6230 edge.from_entity == "models.go::type::Service"
6231 && edge.to_entity == "models.go::type::Dependency"
6232 }),
6233 "Service should keep its Dependency edge. Edges: {:?}",
6234 graph.edges
6235 );
6236 }
6237
6238 #[cfg(feature = "lang-swift")]
6239 #[test]
6240 fn test_swift_extension_member_resolves_through_receiver_type() {
6241 let (dir, registry) = create_test_repo();
6242 let root = dir.path();
6243
6244 write_file(
6245 root,
6246 "Example.swift",
6247 r#"
6248struct Widget {
6249 let name: String
6250}
6251
6252extension Widget {
6253 func render() -> String { return name }
6254}
6255
6256func draw(widget: Widget) {
6257 print(widget.render())
6258}
6259"#,
6260 );
6261
6262 let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], ®istry);
6263 let draw = graph
6264 .entities
6265 .values()
6266 .find(|entity| entity.name == "draw")
6267 .expect("draw function should be in the graph");
6268 let extension = graph
6269 .entities
6270 .values()
6271 .find(|entity| entity.entity_type == "extension")
6272 .expect("extension should be in the graph");
6273 assert_eq!(extension.name, "Widget");
6274 let render = graph
6275 .entities
6276 .values()
6277 .find(|entity| entity.name == "render")
6278 .expect("extension method should be in the graph");
6279 assert_eq!(render.parent_id.as_deref(), Some(extension.id.as_str()));
6280
6281 assert!(
6282 graph.edges.iter().any(|edge| {
6283 edge.from_entity == draw.id
6284 && edge.to_entity == render.id
6285 && edge.ref_type == RefType::Calls
6286 }),
6287 "draw should call Widget.render. Edges: {:?}",
6288 graph.edges
6289 );
6290
6291 let render_dependents = graph.get_dependents(&render.id);
6292 assert!(
6293 render_dependents.iter().any(|entity| entity.id == draw.id),
6294 "render should be impacted by draw. Dependents: {:?}",
6295 render_dependents
6296 .iter()
6297 .map(|entity| &entity.name)
6298 .collect::<Vec<_>>()
6299 );
6300 }
6301
6302 #[cfg(feature = "lang-swift")]
6303 #[test]
6304 fn test_swift_extension_member_resolves_without_prebuilt_lookup() {
6305 let (_dir, registry) = create_test_repo();
6306 let source = r#"
6307struct Widget {
6308 let name: String
6309}
6310
6311extension Widget {
6312 func render() -> String { return name }
6313}
6314
6315func draw(widget: Widget) {
6316 print(widget.render())
6317}
6318"#;
6319
6320 let all_entities = registry.extract_entities("Example.swift", source);
6321 let extension = all_entities
6322 .iter()
6323 .find(|entity| entity.entity_type == "extension")
6324 .expect("extension should be extracted");
6325 assert_eq!(extension.name, "Widget");
6326 let render = all_entities
6327 .iter()
6328 .find(|entity| entity.name == "render")
6329 .expect("extension method should be extracted");
6330 assert_eq!(render.parent_id.as_deref(), Some(extension.id.as_str()));
6331 let entity_map: HashMap<String, EntityInfo> = all_entities
6332 .iter()
6333 .map(|entity| {
6334 (
6335 entity.id.clone(),
6336 EntityInfo {
6337 id: entity.id.clone(),
6338 name: entity.name.clone(),
6339 entity_type: entity.entity_type.clone(),
6340 file_path: entity.file_path.clone(),
6341 parent_id: entity.parent_id.clone(),
6342 start_line: entity.start_line,
6343 end_line: entity.end_line,
6344 },
6345 )
6346 })
6347 .collect();
6348
6349 let result = scope_resolve::resolve_with_scopes(
6350 Path::new("."),
6351 &["Example.swift".into()],
6352 &all_entities,
6353 &entity_map,
6354 Some(vec![(
6355 "Example.swift".into(),
6356 source.into(),
6357 registry
6358 .extract_entities_with_tree("Example.swift", source)
6359 .and_then(|(_, tree)| tree)
6360 .expect("Swift parser should produce a tree"),
6361 )]),
6362 );
6363 let draw = all_entities
6364 .iter()
6365 .find(|entity| entity.name == "draw")
6366 .expect("draw function should be extracted");
6367
6368 assert!(
6369 result.edges.iter().any(|(from, to, ref_type)| {
6370 from == &draw.id && to == &render.id && *ref_type == RefType::Calls
6371 }),
6372 "fallback scope resolver should resolve draw to Widget.render. Edges: {:?}",
6373 result.edges
6374 );
6375 }
6376
6377 #[test]
6378 fn test_extract_references() {
6379 let content = "function processData(input) {\n const result = validateInput(input);\n return transform(result);\n}";
6380 let refs =
6381 extract_references_from_content(content, "processData", &[], StripStrategy::Generic);
6382 assert!(refs.contains(&"validateInput"));
6383 assert!(refs.contains(&"transform"));
6384 assert!(!refs.contains(&"processData")); }
6386
6387 #[test]
6388 fn test_container_does_not_inherit_child_call_edges() {
6389 let (dir, registry) = create_test_repo();
6390 let root = dir.path();
6391
6392 write_file(
6393 root,
6394 "app.ts",
6395 "export function helper() { return 1; }\n\
6396 export class Service {\n\
6397 method() { return helper(); }\n\
6398 }\n\
6399 export class InlineService { method() { return helper(); } }\n",
6400 );
6401
6402 let (graph, _) = EntityGraph::build(root, &["app.ts".into()], ®istry);
6403 let helper_id = "app.ts::function::helper";
6404
6405 for (class_id, method_id) in [
6406 ("app.ts::class::Service", "app.ts::class::Service::method"),
6407 (
6408 "app.ts::class::InlineService",
6409 "app.ts::class::InlineService::method",
6410 ),
6411 ] {
6412 assert!(
6413 graph.edges.iter().any(|edge| {
6414 edge.from_entity == method_id
6415 && edge.to_entity == helper_id
6416 && edge.ref_type == RefType::Calls
6417 }),
6418 "{method_id} should call helper. Edges: {:?}",
6419 graph.edges
6420 );
6421 assert!(
6422 !graph
6423 .edges
6424 .iter()
6425 .any(|edge| edge.from_entity == class_id && edge.to_entity == helper_id),
6426 "{class_id} should not call helper. Edges: {:?}",
6427 graph.edges
6428 );
6429 }
6430 }
6431
6432 #[test]
6433 fn test_incremental_container_does_not_inherit_child_call_edges() {
6434 let (dir, registry) = create_test_repo();
6435 let root = dir.path();
6436
6437 write_file(
6438 root,
6439 "app.ts",
6440 "export function helper() { return 1; }\n\
6441 export class Service {\n\
6442 method() { return helper(); }\n\
6443 }\n",
6444 );
6445 let (initial_graph, initial_entities) =
6446 EntityGraph::build(root, &["app.ts".into()], ®istry);
6447
6448 write_file(
6449 root,
6450 "app.ts",
6451 "export function helper() { return 2; }\n\
6452 export function extra() { return 3; }\n\
6453 export class Service {\n\
6454 method() { return helper(); }\n\
6455 }\n",
6456 );
6457
6458 let (graph, _) = EntityGraph::build_incremental(
6459 root,
6460 &["app.ts".into()],
6461 &["app.ts".into()],
6462 vec![],
6463 initial_graph.edges,
6464 initial_entities,
6465 ®istry,
6466 );
6467
6468 let class_id = "app.ts::class::Service";
6469 let method_id = "app.ts::class::Service::method";
6470 let helper_id = "app.ts::function::helper";
6471 assert!(
6472 graph.edges.iter().any(|edge| {
6473 edge.from_entity == method_id
6474 && edge.to_entity == helper_id
6475 && edge.ref_type == RefType::Calls
6476 }),
6477 "{method_id} should call helper. Edges: {:?}",
6478 graph.edges
6479 );
6480 assert!(
6481 !graph
6482 .edges
6483 .iter()
6484 .any(|edge| edge.from_entity == class_id && edge.to_entity == helper_id),
6485 "{class_id} should not call helper. Edges: {:?}",
6486 graph.edges
6487 );
6488 }
6489
6490 #[test]
6491 fn test_same_line_container_and_child_refs_use_byte_spans() {
6492 let (dir, registry) = create_test_repo();
6493 let root = dir.path();
6494
6495 write_file(
6496 root,
6497 "app.ts",
6498 "export function helper() { return 1; }\n\
6499 export function other() { return 2; }\n\
6500 export class Service { static { helper(); } method() { return other(); } }\n",
6501 );
6502
6503 let (graph, _) = EntityGraph::build(root, &["app.ts".into()], ®istry);
6504 let class_id = "app.ts::class::Service";
6505 let method_id = "app.ts::class::Service::method";
6506 let helper_id = "app.ts::function::helper";
6507 let other_id = "app.ts::function::other";
6508
6509 assert!(
6510 graph.edges.iter().any(|edge| {
6511 edge.from_entity == class_id
6512 && edge.to_entity == helper_id
6513 && edge.ref_type == RefType::Calls
6514 }),
6515 "{class_id} should own the static-block helper call. Edges: {:?}",
6516 graph.edges
6517 );
6518 assert!(
6519 graph.edges.iter().any(|edge| {
6520 edge.from_entity == method_id
6521 && edge.to_entity == other_id
6522 && edge.ref_type == RefType::Calls
6523 }),
6524 "{method_id} should own the method-body other call. Edges: {:?}",
6525 graph.edges
6526 );
6527 assert!(
6528 !graph
6529 .edges
6530 .iter()
6531 .any(|edge| edge.from_entity == method_id && edge.to_entity == helper_id),
6532 "{method_id} should not inherit the static-block helper call. Edges: {:?}",
6533 graph.edges
6534 );
6535 assert!(
6536 !graph
6537 .edges
6538 .iter()
6539 .any(|edge| edge.from_entity == class_id && edge.to_entity == other_id),
6540 "{class_id} should not inherit the method-body other call. Edges: {:?}",
6541 graph.edges
6542 );
6543 }
6544
6545 #[test]
6546 fn test_extract_references_skips_keywords() {
6547 let content = "function foo() { if (true) { return false; } }";
6548 let refs = extract_references_from_content(content, "foo", &[], StripStrategy::Generic);
6549 assert!(!refs.contains(&"if"));
6550 assert!(!refs.contains(&"true"));
6551 assert!(!refs.contains(&"return"));
6552 assert!(!refs.contains(&"false"));
6553 }
6554
6555 #[test]
6556 fn test_infer_ref_type_call() {
6557 assert_eq!(
6558 infer_ref_type("validateInput(data)", "validateInput"),
6559 RefType::Calls,
6560 );
6561 }
6562
6563 #[test]
6564 fn test_infer_ref_type_type() {
6565 assert_eq!(
6566 infer_ref_type("let x: MyType = something", "MyType"),
6567 RefType::TypeRef,
6568 );
6569 }
6570
6571 #[test]
6572 fn test_infer_ref_type_multibyte_utf8() {
6573 assert_eq!(infer_ref_type("let café = foo(x)", "foo"), RefType::Calls,);
6575 assert_eq!(
6576 infer_ref_type(
6577 "class HandicapfrPublicationFieldsEnum:\n É = 1\n bar()",
6578 "bar"
6579 ),
6580 RefType::Calls,
6581 );
6582 assert_eq!(
6584 infer_ref_type("// 日本語コメント\nlet x = 1", "missing"),
6585 RefType::TypeRef,
6586 );
6587 }
6588
6589 #[test]
6590 fn test_dot_chain_self_resolution() {
6591 let (dir, registry) = create_test_repo();
6592 let root = dir.path();
6593
6594 write_file(
6595 root,
6596 "service.py",
6597 "\
6598class MyService:
6599 def process(self):
6600 return self.validate()
6601
6602 def validate(self):
6603 return True
6604",
6605 );
6606
6607 let (graph, _) = EntityGraph::build(root, &["service.py".into()], ®istry);
6608
6609 let process_id = graph
6611 .entities
6612 .keys()
6613 .find(|id| id.contains("process"))
6614 .expect("process entity should exist");
6615 let deps = graph.get_dependencies(process_id);
6616 assert!(
6617 deps.iter().any(|d| d.name == "validate"),
6618 "process should depend on validate via self.validate(). Deps: {:?}",
6619 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6620 );
6621 }
6622
6623 #[test]
6624 fn test_dot_chain_this_resolution() {
6625 let (dir, registry) = create_test_repo();
6626 let root = dir.path();
6627
6628 write_file(
6629 root,
6630 "service.ts",
6631 "\
6632class UserService {
6633 process() {
6634 return this.validate();
6635 }
6636 validate() {
6637 return true;
6638 }
6639}
6640",
6641 );
6642
6643 let (graph, _) = EntityGraph::build(root, &["service.ts".into()], ®istry);
6644
6645 let process_id = graph
6646 .entities
6647 .keys()
6648 .find(|id| id.contains("process"))
6649 .expect("process entity should exist");
6650 let deps = graph.get_dependencies(process_id);
6651 assert!(
6652 deps.iter().any(|d| d.name == "validate"),
6653 "process should depend on validate via this.validate(). Deps: {:?}",
6654 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6655 );
6656 }
6657
6658 #[cfg(feature = "lang-swift")]
6659 #[test]
6660 fn test_swift_bare_instance_property_receiver_resolution() {
6661 let (dir, registry) = create_test_repo();
6662 let root = dir.path();
6663
6664 write_file(
6665 root,
6666 "Example.swift",
6667 "\
6668class Connection {
6669 func execute(query: String) {}
6670 func commit() {}
6671}
6672
6673class Transaction {
6674 let conn: Connection
6675 init(conn: Connection) { self.conn = conn }
6676
6677 func execute(query: String) {
6678 conn.execute(query: query)
6679 }
6680
6681 func commit() {
6682 conn.commit()
6683 }
6684}
6685",
6686 );
6687
6688 let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], ®istry);
6689
6690 let transaction_execute_id = graph
6691 .entities
6692 .iter()
6693 .find(|(id, info)| info.name == "execute" && id.contains("Transaction"))
6694 .map(|(id, _)| id.clone())
6695 .expect("Transaction.execute entity should exist");
6696 let execute_deps = graph.get_dependencies(&transaction_execute_id);
6697 assert!(
6698 execute_deps.iter().any(|d| {
6699 d.name == "execute"
6700 && d.parent_id
6701 .as_deref()
6702 .map_or(false, |parent| parent.contains("Connection"))
6703 }),
6704 "Transaction.execute should depend on Connection.execute. Deps: {:?}",
6705 execute_deps
6706 .iter()
6707 .map(|d| (&d.name, &d.parent_id))
6708 .collect::<Vec<_>>()
6709 );
6710
6711 let transaction_commit_id = graph
6712 .entities
6713 .iter()
6714 .find(|(id, info)| info.name == "commit" && id.contains("Transaction"))
6715 .map(|(id, _)| id.clone())
6716 .expect("Transaction.commit entity should exist");
6717 let commit_deps = graph.get_dependencies(&transaction_commit_id);
6718 assert!(
6719 commit_deps.iter().any(|d| {
6720 d.name == "commit"
6721 && d.parent_id
6722 .as_deref()
6723 .map_or(false, |parent| parent.contains("Connection"))
6724 }),
6725 "Transaction.commit should depend on Connection.commit. Deps: {:?}",
6726 commit_deps
6727 .iter()
6728 .map(|d| (&d.name, &d.parent_id))
6729 .collect::<Vec<_>>()
6730 );
6731 }
6732
6733 #[cfg(feature = "lang-swift")]
6734 #[test]
6735 fn test_swift_static_method_does_not_resolve_instance_property_receiver() {
6736 let (dir, registry) = create_test_repo();
6737 let root = dir.path();
6738
6739 write_file(
6740 root,
6741 "Example.swift",
6742 "\
6743class Connection {
6744 func execute(query: String) {}
6745}
6746
6747class Transaction {
6748 let conn: Connection
6749
6750 static func run() {
6751 conn.execute(query: \"SELECT 1\")
6752 }
6753}
6754",
6755 );
6756
6757 let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], ®istry);
6758
6759 let run_id = graph
6760 .entities
6761 .keys()
6762 .find(|id| id.contains("Transaction") && id.contains("run"))
6763 .expect("Transaction.run entity should exist");
6764 let deps = graph.get_dependencies(run_id);
6765 assert!(
6766 !deps.iter().any(|d| {
6767 d.name == "execute"
6768 && d.parent_id
6769 .as_deref()
6770 .map_or(false, |parent| parent.contains("Connection"))
6771 }),
6772 "static Transaction.run should not depend on Connection.execute via instance property. Deps: {:?}",
6773 deps.iter()
6774 .map(|d| (&d.name, &d.parent_id))
6775 .collect::<Vec<_>>()
6776 );
6777 }
6778
6779 #[cfg(feature = "lang-swift")]
6780 #[test]
6781 fn test_swift_multi_binding_property_receivers_resolve() {
6782 let (dir, registry) = create_test_repo();
6783 let root = dir.path();
6784
6785 write_file(
6786 root,
6787 "Example.swift",
6788 "\
6789class PrimaryConnection {
6790 func execute(query: String) {}
6791}
6792
6793class BackupConnection {
6794 func flush() {}
6795}
6796
6797class Transaction {
6798 let conn: PrimaryConnection, backup: BackupConnection
6799
6800 func run(query: String) {
6801 conn.execute(query: query)
6802 backup.flush()
6803 }
6804}
6805",
6806 );
6807
6808 let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], ®istry);
6809
6810 let run_id = graph
6811 .entities
6812 .keys()
6813 .find(|id| id.contains("Transaction") && id.contains("run"))
6814 .expect("Transaction.run entity should exist");
6815 let deps = graph.get_dependencies(run_id);
6816 assert!(
6817 deps.iter().any(|d| {
6818 d.name == "execute"
6819 && d.parent_id
6820 .as_deref()
6821 .map_or(false, |parent| parent.contains("PrimaryConnection"))
6822 }),
6823 "conn.execute should resolve to PrimaryConnection.execute. Deps: {:?}",
6824 deps.iter()
6825 .map(|d| (&d.name, &d.parent_id))
6826 .collect::<Vec<_>>()
6827 );
6828 assert!(
6829 deps.iter().any(|d| {
6830 d.name == "flush"
6831 && d.parent_id
6832 .as_deref()
6833 .map_or(false, |parent| parent.contains("BackupConnection"))
6834 }),
6835 "backup.flush should resolve to BackupConnection.flush. Deps: {:?}",
6836 deps.iter()
6837 .map(|d| (&d.name, &d.parent_id))
6838 .collect::<Vec<_>>()
6839 );
6840 }
6841
6842 #[cfg(feature = "lang-swift")]
6843 #[test]
6844 fn test_swift_nested_local_binding_shadows_instance_property_receiver() {
6845 let (dir, registry) = create_test_repo();
6846 let root = dir.path();
6847
6848 write_file(
6849 root,
6850 "Example.swift",
6851 "\
6852class Connection {
6853 func execute(query: String) {}
6854}
6855
6856class MockConnection {
6857 func execute(query: String) {}
6858}
6859
6860class Transaction {
6861 let conn: Connection
6862 init(conn: Connection) { self.conn = conn }
6863
6864 func execute(query: String, useMock: Bool) {
6865 if useMock {
6866 let conn = MockConnection()
6867 conn.execute(query: query)
6868 }
6869 }
6870}
6871",
6872 );
6873
6874 let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], ®istry);
6875
6876 let transaction_execute_id = graph
6877 .entities
6878 .iter()
6879 .find(|(id, info)| info.name == "execute" && id.contains("Transaction"))
6880 .map(|(id, _)| id.clone())
6881 .expect("Transaction.execute entity should exist");
6882 let deps = graph.get_dependencies(&transaction_execute_id);
6883 assert!(
6884 deps.iter().any(|d| {
6885 d.name == "execute"
6886 && d.parent_id
6887 .as_deref()
6888 .map_or(false, |parent| parent.contains("MockConnection"))
6889 }),
6890 "nested local conn should resolve to MockConnection.execute. Deps: {:?}",
6891 deps.iter()
6892 .map(|d| (&d.name, &d.parent_id))
6893 .collect::<Vec<_>>()
6894 );
6895 assert!(
6896 !deps.iter().any(|d| {
6897 d.name == "execute"
6898 && d.parent_id.as_deref().map_or(false, |parent| {
6899 parent.contains("Connection") && !parent.contains("MockConnection")
6900 })
6901 }),
6902 "nested local conn should shadow Transaction.conn. Deps: {:?}",
6903 deps.iter()
6904 .map(|d| (&d.name, &d.parent_id))
6905 .collect::<Vec<_>>()
6906 );
6907 }
6908
6909 #[test]
6910 fn test_typescript_bare_identifier_does_not_resolve_instance_property() {
6911 let (dir, registry) = create_test_repo();
6912 let root = dir.path();
6913
6914 write_file(
6915 root,
6916 "service.ts",
6917 "\
6918class Connection {
6919 execute() {
6920 return true;
6921 }
6922}
6923
6924class Transaction {
6925 conn: Connection;
6926 constructor(conn: Connection) {
6927 this.conn = conn;
6928 }
6929
6930 run() {
6931 return conn.execute();
6932 }
6933}
6934",
6935 );
6936
6937 let (graph, _) = EntityGraph::build(root, &["service.ts".into()], ®istry);
6938
6939 let run_id = graph
6940 .entities
6941 .keys()
6942 .find(|id| id.contains("Transaction") && id.contains("run"))
6943 .expect("Transaction.run entity should exist");
6944 let deps = graph.get_dependencies(run_id);
6945 assert!(
6946 !deps.iter().any(|d| {
6947 d.name == "execute"
6948 && d.parent_id
6949 .as_deref()
6950 .map_or(false, |parent| parent.contains("Connection"))
6951 }),
6952 "bare conn.execute() should not resolve through a TypeScript instance property. Deps: {:?}",
6953 deps.iter()
6954 .map(|d| (&d.name, &d.parent_id))
6955 .collect::<Vec<_>>()
6956 );
6957 }
6958
6959 #[test]
6960 fn test_dot_chain_class_static() {
6961 let (dir, registry) = create_test_repo();
6962 let root = dir.path();
6963
6964 write_file(
6965 root,
6966 "utils.ts",
6967 "\
6968class MathUtils {
6969 static compute() { return 1; }
6970}
6971function caller() { return MathUtils.compute(); }
6972",
6973 );
6974
6975 let (graph, _) = EntityGraph::build(root, &["utils.ts".into()], ®istry);
6976
6977 let caller_id = graph
6978 .entities
6979 .keys()
6980 .find(|id| id.contains("caller"))
6981 .expect("caller entity should exist");
6982 let deps = graph.get_dependencies(caller_id);
6983 assert!(
6984 deps.iter().any(|d| d.name == "compute"),
6985 "caller should depend on compute via MathUtils.compute(). Deps: {:?}",
6986 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6987 );
6988 }
6989
6990 #[test]
6991 fn test_protocols_are_member_containers() {
6992 assert!(is_nominal_member_container("protocol"));
6993 assert!(is_scope_member_container("protocol"));
6994 }
6995
6996 #[test]
6997 fn test_js_ts_import_resolution() {
6998 let (dir, registry) = create_test_repo();
6999 let root = dir.path();
7000
7001 write_file(
7002 root,
7003 "helper.ts",
7004 "\
7005export function helper() { return 1; }
7006",
7007 );
7008 write_file(
7009 root,
7010 "main.ts",
7011 "\
7012import { helper } from './helper';
7013export function main() { return helper(); }
7014",
7015 );
7016
7017 let (graph, _) =
7018 EntityGraph::build(root, &["helper.ts".into(), "main.ts".into()], ®istry);
7019
7020 let main_id = graph
7021 .entities
7022 .keys()
7023 .find(|id| id.contains("main"))
7024 .expect("main entity should exist");
7025 let deps = graph.get_dependencies(main_id);
7026 assert!(
7027 deps.iter().any(|d| d.name == "helper"),
7028 "main should depend on helper via JS import. Deps: {:?}",
7029 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
7030 );
7031 }
7032
7033 #[test]
7034 fn test_direct_dependencies_match_full_graph_for_js_ts_import_forms() {
7035 let (dir, registry) = create_test_repo();
7036 let root = dir.path();
7037
7038 write_file(
7039 root,
7040 "lib.ts",
7041 "\
7042export function namedThing() { return 1; }
7043export default function defaultThing() { return 2; }
7044",
7045 );
7046 write_file(
7047 root,
7048 "consumer.ts",
7049 "\
7050import defaultThing, { namedThing } from './lib';
7051import * as lib from './lib';
7052
7053export function useEverything() {
7054 return defaultThing() + namedThing() + lib.namedThing();
7055}
7056",
7057 );
7058 let files = vec!["lib.ts".to_string(), "consumer.ts".to_string()];
7059 assert_direct_dependencies_match_full(
7060 root,
7061 &files,
7062 ®istry,
7063 "consumer.ts::function::useEverything",
7064 );
7065 }
7066
7067 #[test]
7068 fn test_js_ts_relative_import_resolution_uses_full_path() {
7069 let (dir, registry) = create_test_repo();
7070 let root = dir.path();
7071
7072 write_file(
7073 root,
7074 "src/a/util.ts",
7075 "\
7076export function helper() { return 1; }
7077",
7078 );
7079 write_file(
7080 root,
7081 "src/b/util.ts",
7082 "\
7083export function helper() { return 2; }
7084",
7085 );
7086 write_file(
7087 root,
7088 "src/main.ts",
7089 "\
7090import { helper } from './b/util';
7091export function caller() { return helper(); }
7092",
7093 );
7094
7095 let (graph, _) = EntityGraph::build(
7096 root,
7097 &[
7098 "src/a/util.ts".into(),
7099 "src/b/util.ts".into(),
7100 "src/main.ts".into(),
7101 ],
7102 ®istry,
7103 );
7104
7105 let caller_id = graph
7106 .entities
7107 .keys()
7108 .find(|id| id.contains("caller"))
7109 .expect("caller entity should exist");
7110 let deps = graph.get_dependencies(caller_id);
7111 assert!(
7112 deps.iter()
7113 .any(|d| d.name == "helper" && d.file_path == "src/b/util.ts"),
7114 "caller should resolve helper to src/b/util.ts. Deps: {:?}",
7115 deps.iter()
7116 .map(|d| (&d.name, &d.file_path))
7117 .collect::<Vec<_>>()
7118 );
7119 assert!(
7120 !deps
7121 .iter()
7122 .any(|d| d.name == "helper" && d.file_path == "src/a/util.ts"),
7123 "caller should not resolve helper to src/a/util.ts. Deps: {:?}",
7124 deps.iter()
7125 .map(|d| (&d.name, &d.file_path))
7126 .collect::<Vec<_>>()
7127 );
7128 }
7129
7130 #[test]
7131 fn test_js_ts_relative_import_with_extension_prefers_exact_file() {
7132 let (dir, registry) = create_test_repo();
7133 let root = dir.path();
7134
7135 write_file(
7136 root,
7137 "src/util.js",
7138 "\
7139export function helper() { return 1; }
7140",
7141 );
7142 write_file(
7143 root,
7144 "src/util.ts",
7145 "\
7146export function helper() { return 2; }
7147",
7148 );
7149 write_file(
7150 root,
7151 "src/main.ts",
7152 "\
7153import { helper } from './util.ts';
7154export function caller() { return helper(); }
7155",
7156 );
7157
7158 let (graph, _) = EntityGraph::build(
7159 root,
7160 &[
7161 "src/util.js".into(),
7162 "src/util.ts".into(),
7163 "src/main.ts".into(),
7164 ],
7165 ®istry,
7166 );
7167
7168 let caller_id = graph
7169 .entities
7170 .keys()
7171 .find(|id| id.contains("caller"))
7172 .expect("caller entity should exist");
7173 let deps = graph.get_dependencies(caller_id);
7174 assert!(
7175 deps.iter()
7176 .any(|d| d.name == "helper" && d.file_path == "src/util.ts"),
7177 "caller should resolve helper to explicit src/util.ts. Deps: {:?}",
7178 deps.iter()
7179 .map(|d| (&d.name, &d.file_path))
7180 .collect::<Vec<_>>()
7181 );
7182 assert!(
7183 !deps
7184 .iter()
7185 .any(|d| d.name == "helper" && d.file_path == "src/util.js"),
7186 "caller should not resolve explicit ./util.ts to src/util.js. Deps: {:?}",
7187 deps.iter()
7188 .map(|d| (&d.name, &d.file_path))
7189 .collect::<Vec<_>>()
7190 );
7191 }
7192
7193 #[test]
7194 fn test_js_ts_default_import_resolves_static_member() {
7195 let (dir, registry) = create_test_repo();
7196 let root = dir.path();
7197
7198 write_file(
7199 root,
7200 "base.ts",
7201 "\
7202export default class Widget {
7203 static make(): string { return 'ok'; }
7204}
7205",
7206 );
7207 write_file(
7208 root,
7209 "consumer.ts",
7210 "\
7211import RenamedWidget from './base';
7212export function useWidget(): string { return RenamedWidget.make(); }
7213",
7214 );
7215
7216 let (graph, _) =
7217 EntityGraph::build(root, &["base.ts".into(), "consumer.ts".into()], ®istry);
7218
7219 let use_widget_id = graph
7220 .entities
7221 .keys()
7222 .find(|id| id.contains("useWidget"))
7223 .expect("useWidget entity should exist");
7224 let deps = graph.get_dependencies(use_widget_id);
7225 assert!(
7226 deps.iter()
7227 .any(|d| d.name == "make" && d.file_path == "base.ts"),
7228 "default import alias should resolve the static member. Deps: {:?}",
7229 deps.iter()
7230 .map(|d| (&d.name, &d.file_path))
7231 .collect::<Vec<_>>()
7232 );
7233 }
7234
7235 #[test]
7236 fn test_js_ts_re_export_alias_resolves_through_barrel() {
7237 let (dir, registry) = create_test_repo();
7238 let root = dir.path();
7239
7240 write_file(
7241 root,
7242 "lib.ts",
7243 "\
7244export function core(): string { return 'core'; }
7245",
7246 );
7247 write_file(
7248 root,
7249 "barrel.ts",
7250 "\
7251export { core as publicCore } from './lib';
7252",
7253 );
7254 write_file(
7255 root,
7256 "consumer.ts",
7257 "\
7258import { publicCore } from './barrel';
7259export function usePublicCore(): string { return publicCore(); }
7260",
7261 );
7262
7263 let (graph, _) = EntityGraph::build(
7264 root,
7265 &["lib.ts".into(), "barrel.ts".into(), "consumer.ts".into()],
7266 ®istry,
7267 );
7268
7269 let public_core = graph
7270 .entities
7271 .values()
7272 .find(|entity| {
7273 entity.name == "publicCore"
7274 && entity.file_path == "barrel.ts"
7275 && entity.entity_type == "export"
7276 })
7277 .expect("barrel export alias entity should exist");
7278 let alias_deps = graph.get_dependencies(&public_core.id);
7279 assert!(
7280 alias_deps
7281 .iter()
7282 .any(|d| d.name == "core" && d.file_path == "lib.ts"),
7283 "barrel export alias should depend on lib.ts core. Deps: {:?}",
7284 alias_deps
7285 .iter()
7286 .map(|d| (&d.name, &d.file_path))
7287 .collect::<Vec<_>>()
7288 );
7289
7290 let use_public_core_id = graph
7291 .entities
7292 .keys()
7293 .find(|id| id.contains("usePublicCore"))
7294 .expect("usePublicCore entity should exist");
7295 let consumer_deps = graph.get_dependencies(use_public_core_id);
7296 assert!(
7297 consumer_deps
7298 .iter()
7299 .any(|d| d.name == "publicCore" && d.file_path == "barrel.ts"),
7300 "consumer should resolve publicCore through the barrel export. Deps: {:?}",
7301 consumer_deps
7302 .iter()
7303 .map(|d| (&d.name, &d.file_path))
7304 .collect::<Vec<_>>()
7305 );
7306 }
7307
7308 #[test]
7309 fn test_js_ts_re_export_alias_overrides_colliding_default_import_name() {
7310 let (dir, registry) = create_test_repo();
7311 let root = dir.path();
7312
7313 write_file(
7314 root,
7315 "default_source.ts",
7316 "\
7317export default function Public(): string { return 'default'; }
7318",
7319 );
7320 write_file(
7321 root,
7322 "named_source.ts",
7323 "\
7324export function named(): string { return 'named'; }
7325",
7326 );
7327 write_file(
7328 root,
7329 "barrel.ts",
7330 "\
7331import Public from './default_source';
7332export { named as Public } from './named_source';
7333",
7334 );
7335
7336 let (graph, _) = EntityGraph::build(
7337 root,
7338 &[
7339 "default_source.ts".into(),
7340 "named_source.ts".into(),
7341 "barrel.ts".into(),
7342 ],
7343 ®istry,
7344 );
7345
7346 let public_export = graph
7347 .entities
7348 .values()
7349 .find(|entity| {
7350 entity.name == "Public"
7351 && entity.file_path == "barrel.ts"
7352 && entity.entity_type == "export"
7353 })
7354 .expect("barrel export alias entity should exist");
7355 let alias_deps = graph.get_dependencies(&public_export.id);
7356 assert!(
7357 alias_deps
7358 .iter()
7359 .any(|d| d.name == "named" && d.file_path == "named_source.ts"),
7360 "barrel export alias should prefer the re-export target over the colliding default import. Deps: {:?}",
7361 alias_deps
7362 .iter()
7363 .map(|d| (&d.name, &d.file_path))
7364 .collect::<Vec<_>>()
7365 );
7366 assert!(
7367 !alias_deps
7368 .iter()
7369 .any(|d| d.name == "Public" && d.file_path == "default_source.ts"),
7370 "colliding default import should not win over the re-export alias. Deps: {:?}",
7371 alias_deps
7372 .iter()
7373 .map(|d| (&d.name, &d.file_path))
7374 .collect::<Vec<_>>()
7375 );
7376 }
7377
7378 #[test]
7379 fn test_direct_dependencies_match_full_graph_for_js_ts_re_exports() {
7380 let (dir, registry) = create_test_repo();
7381 let root = dir.path();
7382
7383 write_file(
7384 root,
7385 "lib.ts",
7386 "\
7387export function core(): string { return 'core'; }
7388",
7389 );
7390 write_file(
7391 root,
7392 "barrel.ts",
7393 "\
7394export { core as publicCore } from './lib';
7395",
7396 );
7397 write_file(
7398 root,
7399 "consumer.ts",
7400 "\
7401import { publicCore } from './barrel';
7402export function usePublicCore(): string { return publicCore(); }
7403",
7404 );
7405 let files = vec![
7406 "lib.ts".to_string(),
7407 "barrel.ts".to_string(),
7408 "consumer.ts".to_string(),
7409 ];
7410
7411 assert_direct_dependencies_match_full(
7412 root,
7413 &files,
7414 ®istry,
7415 "barrel.ts::export::publicCore",
7416 );
7417 assert_direct_dependencies_match_full(
7418 root,
7419 &files,
7420 ®istry,
7421 "consumer.ts::function::usePublicCore",
7422 );
7423 }
7424
7425 #[test]
7426 fn test_js_ts_namespace_import_resolves_re_export_alias() {
7427 let (dir, registry) = create_test_repo();
7428 let root = dir.path();
7429
7430 write_file(
7431 root,
7432 "lib.ts",
7433 "\
7434export function core(): string { return 'core'; }
7435",
7436 );
7437 write_file(
7438 root,
7439 "barrel.ts",
7440 "\
7441export { core as publicCore } from './lib';
7442",
7443 );
7444 write_file(
7445 root,
7446 "consumer.ts",
7447 "\
7448import * as barrel from './barrel';
7449export function usePublicCore(): string { return barrel.publicCore(); }
7450",
7451 );
7452
7453 let (graph, _) = EntityGraph::build(
7454 root,
7455 &["lib.ts".into(), "barrel.ts".into(), "consumer.ts".into()],
7456 ®istry,
7457 );
7458
7459 let use_public_core_id = graph
7460 .entities
7461 .keys()
7462 .find(|id| id.contains("usePublicCore"))
7463 .expect("usePublicCore entity should exist");
7464 let consumer_deps = graph.get_dependencies(use_public_core_id);
7465 assert!(
7466 consumer_deps
7467 .iter()
7468 .any(|d| d.name == "publicCore" && d.file_path == "barrel.ts"),
7469 "namespace import should resolve the exported barrel alias. Deps: {:?}",
7470 consumer_deps
7471 .iter()
7472 .map(|d| (&d.name, &d.file_path))
7473 .collect::<Vec<_>>()
7474 );
7475 }
7476
7477 #[test]
7478 fn test_js_ts_object_literal_receiver_resolves_owned_member() {
7479 let (dir, registry) = create_test_repo();
7480 let root = dir.path();
7481
7482 write_file(
7483 root,
7484 "service.ts",
7485 "\
7486export const other = {
7487 open() { return 'other'; }
7488};
7489export const svc = {
7490 open() { return 'svc'; }
7491};
7492export function run(): string {
7493 return svc.open();
7494}
7495",
7496 );
7497
7498 let (graph, _) = EntityGraph::build(root, &["service.ts".into()], ®istry);
7499
7500 let run_id = graph
7501 .entities
7502 .keys()
7503 .find(|id| id.contains("run"))
7504 .expect("run entity should exist");
7505 let deps = graph.get_dependencies(run_id);
7506 assert!(
7507 deps.iter()
7508 .any(|d| d.name == "open"
7509 && d.parent_id.as_deref().is_some_and(|id| id.contains("svc"))),
7510 "svc.open() should resolve to the object literal member owned by svc. Deps: {:?}",
7511 deps.iter()
7512 .map(|d| (&d.name, &d.parent_id))
7513 .collect::<Vec<_>>()
7514 );
7515 assert!(
7516 !deps.iter().any(|d| d.name == "open"
7517 && d.parent_id
7518 .as_deref()
7519 .is_some_and(|id| id.contains("other"))),
7520 "svc.open() should not resolve to another object literal member. Deps: {:?}",
7521 deps.iter()
7522 .map(|d| (&d.name, &d.parent_id))
7523 .collect::<Vec<_>>()
7524 );
7525 }
7526
7527 #[test]
7528 fn test_python_relative_import_resolution_uses_full_path() {
7529 let (dir, registry) = create_test_repo();
7530 let root = dir.path();
7531
7532 write_file(
7533 root,
7534 "src/a/util.py",
7535 "\
7536def helper():
7537 return 1
7538",
7539 );
7540 write_file(
7541 root,
7542 "src/b/util.py",
7543 "\
7544def helper():
7545 return 2
7546",
7547 );
7548 write_file(
7549 root,
7550 "src/main.py",
7551 "\
7552from .b.util import helper
7553
7554def caller():
7555 return helper()
7556",
7557 );
7558
7559 let (graph, _) = EntityGraph::build(
7560 root,
7561 &[
7562 "src/a/util.py".into(),
7563 "src/b/util.py".into(),
7564 "src/main.py".into(),
7565 ],
7566 ®istry,
7567 );
7568
7569 let caller_id = graph
7570 .entities
7571 .keys()
7572 .find(|id| id.contains("caller"))
7573 .expect("caller entity should exist");
7574 let deps = graph.get_dependencies(caller_id);
7575 assert!(
7576 deps.iter()
7577 .any(|d| d.name == "helper" && d.file_path == "src/b/util.py"),
7578 "caller should resolve helper to src/b/util.py. Deps: {:?}",
7579 deps.iter()
7580 .map(|d| (&d.name, &d.file_path))
7581 .collect::<Vec<_>>()
7582 );
7583 assert!(
7584 !deps
7585 .iter()
7586 .any(|d| d.name == "helper" && d.file_path == "src/a/util.py"),
7587 "caller should not resolve helper to src/a/util.py. Deps: {:?}",
7588 deps.iter()
7589 .map(|d| (&d.name, &d.file_path))
7590 .collect::<Vec<_>>()
7591 );
7592 }
7593
7594 #[test]
7595 fn test_python_absolute_import_resolution_uses_full_path() {
7596 let (dir, registry) = create_test_repo();
7597 let root = dir.path();
7598
7599 write_file(
7600 root,
7601 "src/a/util.py",
7602 "\
7603def helper():
7604 return 1
7605",
7606 );
7607 write_file(
7608 root,
7609 "src/b/util.py",
7610 "\
7611def helper():
7612 return 2
7613",
7614 );
7615 write_file(
7616 root,
7617 "src/main.py",
7618 "\
7619from src.b.util import helper
7620
7621def caller():
7622 return helper()
7623",
7624 );
7625
7626 let (graph, _) = EntityGraph::build(
7627 root,
7628 &[
7629 "src/a/util.py".into(),
7630 "src/b/util.py".into(),
7631 "src/main.py".into(),
7632 ],
7633 ®istry,
7634 );
7635
7636 let caller_id = graph
7637 .entities
7638 .keys()
7639 .find(|id| id.contains("caller"))
7640 .expect("caller entity should exist");
7641 let deps = graph.get_dependencies(caller_id);
7642 assert!(
7643 deps.iter()
7644 .any(|d| d.name == "helper" && d.file_path == "src/b/util.py"),
7645 "caller should resolve helper to src/b/util.py. Deps: {:?}",
7646 deps.iter()
7647 .map(|d| (&d.name, &d.file_path))
7648 .collect::<Vec<_>>()
7649 );
7650 assert!(
7651 !deps
7652 .iter()
7653 .any(|d| d.name == "helper" && d.file_path == "src/a/util.py"),
7654 "caller should not resolve helper to src/a/util.py. Deps: {:?}",
7655 deps.iter()
7656 .map(|d| (&d.name, &d.file_path))
7657 .collect::<Vec<_>>()
7658 );
7659 }
7660
7661 #[test]
7662 fn test_js_ts_named_import_does_not_resolve_unrelated_method_receiver() {
7663 let (dir, registry) = create_test_repo();
7664 let root = dir.path();
7665
7666 write_file(
7667 root,
7668 "lib.ts",
7669 "\
7670export function foo() { return 1; }
7671",
7672 );
7673 write_file(
7674 root,
7675 "main.ts",
7676 "\
7677import { foo } from './lib';
7678export function caller(other) { return other.foo(); }
7679export function actual() { return foo(); }
7680",
7681 );
7682
7683 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
7684
7685 let caller_id = graph
7686 .entities
7687 .keys()
7688 .find(|id| id.contains("caller"))
7689 .expect("caller entity should exist");
7690 let caller_deps = graph.get_dependencies(caller_id);
7691 assert!(
7692 !caller_deps
7693 .iter()
7694 .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7695 "other.foo() should not resolve through a bare named import. Deps: {:?}",
7696 caller_deps
7697 .iter()
7698 .map(|d| (&d.name, &d.file_path))
7699 .collect::<Vec<_>>()
7700 );
7701
7702 let actual_id = graph
7703 .entities
7704 .keys()
7705 .find(|id| id.contains("actual"))
7706 .expect("actual entity should exist");
7707 let actual_deps = graph.get_dependencies(actual_id);
7708 assert!(
7709 actual_deps
7710 .iter()
7711 .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7712 "foo() should still resolve through the named import. Deps: {:?}",
7713 actual_deps
7714 .iter()
7715 .map(|d| (&d.name, &d.file_path))
7716 .collect::<Vec<_>>()
7717 );
7718 }
7719
7720 #[test]
7721 fn test_unresolved_method_does_not_block_unrelated_fallback_import() {
7722 let (dir, registry) = create_test_repo();
7723 let root = dir.path();
7724
7725 write_file(
7726 root,
7727 "lib.ts",
7728 "\
7729export const answer = 1;
7730export function foo() { return 1; }
7731",
7732 );
7733 write_file(
7734 root,
7735 "main.ts",
7736 "\
7737import { answer, foo } from './lib';
7738export function caller(other) {
7739 other.foo();
7740 return answer;
7741}
7742",
7743 );
7744
7745 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
7746
7747 let caller_id = graph
7748 .entities
7749 .keys()
7750 .find(|id| id.contains("caller"))
7751 .expect("caller entity should exist");
7752 let deps = graph.get_dependencies(caller_id);
7753 assert!(
7754 deps.iter()
7755 .any(|d| d.name == "answer" && d.file_path == "lib.ts"),
7756 "unresolved other.foo() should not block bare answer import fallback. Deps: {:?}",
7757 deps.iter()
7758 .map(|d| (&d.name, &d.file_path))
7759 .collect::<Vec<_>>()
7760 );
7761 assert!(
7762 !deps
7763 .iter()
7764 .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7765 "other.foo() should not resolve through the named import. Deps: {:?}",
7766 deps.iter()
7767 .map(|d| (&d.name, &d.file_path))
7768 .collect::<Vec<_>>()
7769 );
7770 }
7771
7772 #[test]
7773 fn test_js_ts_namespace_import_respects_receiver_alias() {
7774 let (dir, registry) = create_test_repo();
7775 let root = dir.path();
7776
7777 write_file(
7778 root,
7779 "lib.ts",
7780 "\
7781export function foo() { return 1; }
7782",
7783 );
7784 write_file(
7785 root,
7786 "other.ts",
7787 "\
7788export function foo() { return 2; }
7789",
7790 );
7791 write_file(
7792 root,
7793 "main.ts",
7794 "\
7795import * as lib from './lib';
7796export function caller(other) { return other.foo(); }
7797export function actual() { return lib.foo(); }
7798",
7799 );
7800
7801 let (graph, _) = EntityGraph::build(
7802 root,
7803 &["lib.ts".into(), "other.ts".into(), "main.ts".into()],
7804 ®istry,
7805 );
7806
7807 let caller_id = graph
7808 .entities
7809 .keys()
7810 .find(|id| id.contains("caller"))
7811 .expect("caller entity should exist");
7812 let caller_deps = graph.get_dependencies(caller_id);
7813 assert!(
7814 !caller_deps.iter().any(|d| d.name == "foo"),
7815 "other.foo() should not resolve via namespace import lib. Deps: {:?}",
7816 caller_deps
7817 .iter()
7818 .map(|d| (&d.name, &d.file_path))
7819 .collect::<Vec<_>>()
7820 );
7821
7822 let actual_id = graph
7823 .entities
7824 .keys()
7825 .find(|id| id.contains("actual"))
7826 .expect("actual entity should exist");
7827 let actual_deps = graph.get_dependencies(actual_id);
7828 assert!(
7829 actual_deps
7830 .iter()
7831 .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7832 "lib.foo() should resolve to lib.ts. Deps: {:?}",
7833 actual_deps
7834 .iter()
7835 .map(|d| (&d.name, &d.file_path))
7836 .collect::<Vec<_>>()
7837 );
7838 assert!(
7839 !actual_deps
7840 .iter()
7841 .any(|d| d.name == "foo" && d.file_path == "other.ts"),
7842 "lib.foo() should not resolve to other.ts. Deps: {:?}",
7843 actual_deps
7844 .iter()
7845 .map(|d| (&d.name, &d.file_path))
7846 .collect::<Vec<_>>()
7847 );
7848 }
7849
7850 #[test]
7851 fn test_js_ts_namespace_import_skips_unexported_top_level_entities() {
7852 let (dir, registry) = create_test_repo();
7853 let root = dir.path();
7854
7855 write_file(
7856 root,
7857 "lib.ts",
7858 "\
7859function hidden() { return 1; }
7860export function visible() { return 2; }
7861",
7862 );
7863 write_file(
7864 root,
7865 "main.ts",
7866 "\
7867import * as lib from './lib';
7868export function callVisible() { return lib.visible(); }
7869export function callHidden() { return lib.hidden(); }
7870",
7871 );
7872
7873 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
7874
7875 let visible_id = graph
7876 .entities
7877 .keys()
7878 .find(|id| id.contains("callVisible"))
7879 .expect("callVisible entity should exist");
7880 let visible_deps = graph.get_dependencies(visible_id);
7881 assert!(
7882 visible_deps
7883 .iter()
7884 .any(|d| d.name == "visible" && d.file_path == "lib.ts"),
7885 "lib.visible() should resolve to the exported function. Deps: {:?}",
7886 visible_deps
7887 .iter()
7888 .map(|d| (&d.name, &d.file_path))
7889 .collect::<Vec<_>>()
7890 );
7891
7892 let hidden_id = graph
7893 .entities
7894 .keys()
7895 .find(|id| id.contains("callHidden"))
7896 .expect("callHidden entity should exist");
7897 let hidden_deps = graph.get_dependencies(hidden_id);
7898 assert!(
7899 !hidden_deps
7900 .iter()
7901 .any(|d| d.name == "hidden" && d.file_path == "lib.ts"),
7902 "lib.hidden() should not resolve to a module-private function. Deps: {:?}",
7903 hidden_deps
7904 .iter()
7905 .map(|d| (&d.name, &d.file_path))
7906 .collect::<Vec<_>>()
7907 );
7908 }
7909
7910 #[test]
7911 fn test_js_ts_local_binding_shadows_imported_class_receiver() {
7912 let (dir, registry) = create_test_repo();
7913 let root = dir.path();
7914
7915 write_file(
7916 root,
7917 "lib.ts",
7918 "\
7919export class Service {
7920 static run() { return 1; }
7921}
7922",
7923 );
7924 write_file(
7925 root,
7926 "main.ts",
7927 "\
7928import { Service } from './lib';
7929export function caller(Service) { return Service.run(); }
7930",
7931 );
7932
7933 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
7934
7935 let caller_id = graph
7936 .entities
7937 .keys()
7938 .find(|id| id.contains("caller"))
7939 .expect("caller entity should exist");
7940 let deps = graph.get_dependencies(caller_id);
7941 assert!(
7942 !deps
7943 .iter()
7944 .any(|d| d.name == "run" && d.file_path == "lib.ts"),
7945 "local parameter Service should shadow imported class receiver. Deps: {:?}",
7946 deps.iter()
7947 .map(|d| (&d.name, &d.file_path))
7948 .collect::<Vec<_>>()
7949 );
7950 assert!(
7951 !deps
7952 .iter()
7953 .any(|d| d.name == "Service" && d.file_path == "lib.ts"),
7954 "local parameter Service should shadow imported class name. Deps: {:?}",
7955 deps.iter()
7956 .map(|d| (&d.name, &d.file_path))
7957 .collect::<Vec<_>>()
7958 );
7959 }
7960
7961 #[test]
7962 fn test_js_ts_local_binding_shadows_namespace_receiver() {
7963 let (dir, registry) = create_test_repo();
7964 let root = dir.path();
7965
7966 write_file(
7967 root,
7968 "lib.ts",
7969 "\
7970export function foo() { return 1; }
7971",
7972 );
7973 write_file(
7974 root,
7975 "main.ts",
7976 "\
7977import * as lib from './lib';
7978export function caller(lib) { return lib.foo(); }
7979",
7980 );
7981
7982 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
7983
7984 let caller_id = graph
7985 .entities
7986 .keys()
7987 .find(|id| id.contains("caller"))
7988 .expect("caller entity should exist");
7989 let deps = graph.get_dependencies(caller_id);
7990 assert!(
7991 !deps
7992 .iter()
7993 .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7994 "local parameter lib should shadow namespace import receiver. Deps: {:?}",
7995 deps.iter()
7996 .map(|d| (&d.name, &d.file_path))
7997 .collect::<Vec<_>>()
7998 );
7999 }
8000
8001 #[test]
8002 fn test_js_ts_local_binding_shadows_named_import_call() {
8003 let (dir, registry) = create_test_repo();
8004 let root = dir.path();
8005
8006 write_file(
8007 root,
8008 "lib.ts",
8009 "\
8010export function foo() { return 1; }
8011",
8012 );
8013 write_file(
8014 root,
8015 "main.ts",
8016 "\
8017import { foo } from './lib';
8018export function caller(foo) { return foo(); }
8019",
8020 );
8021
8022 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
8023
8024 let caller_id = graph
8025 .entities
8026 .keys()
8027 .find(|id| id.contains("caller"))
8028 .expect("caller entity should exist");
8029 let deps = graph.get_dependencies(caller_id);
8030 assert!(
8031 !deps
8032 .iter()
8033 .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
8034 "local parameter foo should shadow named import. Deps: {:?}",
8035 deps.iter()
8036 .map(|d| (&d.name, &d.file_path))
8037 .collect::<Vec<_>>()
8038 );
8039 }
8040
8041 #[test]
8042 fn test_nested_local_binding_does_not_hide_parent_reference() {
8043 let (dir, registry) = create_test_repo();
8044 let root = dir.path();
8045
8046 write_file(
8047 root,
8048 "lib.ts",
8049 "\
8050export const answer = 42;
8051",
8052 );
8053 write_file(
8054 root,
8055 "main.ts",
8056 "\
8057import { answer } from './lib';
8058export function outer() {
8059 const value = answer;
8060 function inner() {
8061 const answer = 0;
8062 return answer;
8063 }
8064 return value;
8065}
8066",
8067 );
8068
8069 let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], ®istry);
8070
8071 let outer_id = graph
8072 .entities
8073 .iter()
8074 .find(|(_, entity)| entity.name == "outer")
8075 .map(|(id, _)| id)
8076 .expect("outer entity should exist");
8077 let outer_deps = graph.get_dependencies(outer_id);
8078 assert!(
8079 outer_deps
8080 .iter()
8081 .any(|d| d.name == "answer" && d.file_path == "lib.ts"),
8082 "parent bare reference to imported answer should remain resolved. Deps: {:?}",
8083 outer_deps
8084 .iter()
8085 .map(|d| (&d.name, &d.file_path))
8086 .collect::<Vec<_>>()
8087 );
8088
8089 let inner_id = graph
8090 .entities
8091 .iter()
8092 .find(|(_, entity)| entity.name == "inner")
8093 .map(|(id, _)| id)
8094 .expect("inner entity should exist");
8095 let inner_deps = graph.get_dependencies(inner_id);
8096 assert!(
8097 !inner_deps
8098 .iter()
8099 .any(|d| d.name == "answer" && d.file_path == "lib.ts"),
8100 "nested local binding answer should not resolve to imported answer. Deps: {:?}",
8101 inner_deps
8102 .iter()
8103 .map(|d| (&d.name, &d.file_path))
8104 .collect::<Vec<_>>()
8105 );
8106 }
8107
8108 #[test]
8109 fn test_python_local_binding_shadows_same_file_function() {
8110 let (dir, registry) = create_test_repo();
8111 let root = dir.path();
8112
8113 write_file(
8114 root,
8115 "b.py",
8116 "\
8117def total(items):
8118 return sum(items)
8119
8120def report():
8121 total = 0
8122 for i in range(10):
8123 total = total + i
8124 return total
8125",
8126 );
8127
8128 let (graph, _) = EntityGraph::build(root, &["b.py".into()], ®istry);
8129
8130 let report_id = graph
8131 .entities
8132 .iter()
8133 .find(|(_, entity)| entity.name == "report")
8134 .map(|(id, _)| id)
8135 .expect("report entity should exist");
8136 let deps = graph.get_dependencies(report_id);
8137 assert!(
8138 !deps.iter().any(|d| d.name == "total"),
8139 "local variable total should not resolve to same-file function total. Deps: {:?}",
8140 deps.iter()
8141 .map(|d| (&d.name, &d.file_path))
8142 .collect::<Vec<_>>()
8143 );
8144 }
8145
8146 #[test]
8147 fn test_constructor_return_type_tie_break_uses_stable_source_order() {
8148 let (dir, registry) = create_test_repo();
8149 let root = dir.path();
8150
8151 write_file(
8152 root,
8153 "a_primary.py",
8154 "\
8155class Primary:
8156 def get(self):
8157 return True
8158
8159def make_conn():
8160 return Primary()
8161",
8162 );
8163 write_file(
8164 root,
8165 "holder.py",
8166 "\
8167class Holder:
8168 def __init__(self, conn):
8169 self.conn = conn
8170
8171 def use(self):
8172 return self.conn.get()
8173
8174def wire():
8175 Holder(make_conn())
8176",
8177 );
8178 write_file(
8179 root,
8180 "z_backup.py",
8181 "\
8182class Backup:
8183 def get(self):
8184 return False
8185
8186def make_conn():
8187 return Backup()
8188",
8189 );
8190
8191 let files = vec![
8192 "a_primary.py".to_string(),
8193 "holder.py".to_string(),
8194 "z_backup.py".to_string(),
8195 ];
8196 let (graph, _) = EntityGraph::build(root, &files, ®istry);
8197 let graph_payload = graph_json_payload(&graph);
8198
8199 let use_id = graph
8200 .entities
8201 .iter()
8202 .find(|(_, entity)| entity.name == "use")
8203 .map(|(id, _)| id)
8204 .expect("Holder.use entity should exist");
8205 let deps = graph.get_dependencies(use_id);
8206
8207 assert!(
8208 deps.iter().any(|d| {
8209 d.name == "get"
8210 && d.parent_id
8211 .as_deref()
8212 .map_or(false, |parent| parent.contains("Primary"))
8213 }),
8214 "Holder.use should resolve conn.get to Primary.get. Deps: {:?}",
8215 deps.iter()
8216 .map(|d| (&d.name, &d.parent_id))
8217 .collect::<Vec<_>>()
8218 );
8219 assert!(
8220 !deps.iter().any(|d| {
8221 d.name == "get"
8222 && d.parent_id
8223 .as_deref()
8224 .map_or(false, |parent| parent.contains("Backup"))
8225 }),
8226 "Holder.use should not resolve conn.get to Backup.get. Deps: {:?}",
8227 deps.iter()
8228 .map(|d| (&d.name, &d.parent_id))
8229 .collect::<Vec<_>>()
8230 );
8231
8232 for _ in 0..16 {
8233 let (repeat_graph, _) = EntityGraph::build(root, &files, ®istry);
8234 assert_eq!(graph_json_payload(&repeat_graph), graph_payload);
8235 }
8236 }
8237
8238 #[test]
8239 fn test_rust_impl_container_does_not_inherit_child_build_call() {
8240 let (dir, registry) = create_test_repo();
8241 let root = dir.path();
8242
8243 write_file(
8244 root,
8245 "graph.rs",
8246 "\
8247pub struct EntityGraph;
8248
8249impl EntityGraph {
8250 pub fn build(a: i32, b: i32, c: i32) -> i32 {
8251 a + b + c
8252 }
8253}
8254",
8255 );
8256 write_file(
8257 root,
8258 "server.rs",
8259 "\
8260use crate::graph::EntityGraph;
8261
8262struct SemServer;
8263
8264impl SemServer {
8265 fn find_supported_files() {
8266 let mut builder = ignore::WalkBuilder::new(\".\");
8267 let walker = builder.build();
8268 }
8269
8270 fn get_or_build_graph() {
8271 let _ = EntityGraph::build(1, 2, 3);
8272 }
8273}
8274
8275impl SemServer {
8276 fn metadata(&self) -> i32 {
8277 1
8278 }
8279}
8280",
8281 );
8282
8283 let (graph, _) =
8284 EntityGraph::build(root, &["graph.rs".into(), "server.rs".into()], ®istry);
8285
8286 let sem_server_impls: Vec<_> = graph
8287 .entities
8288 .iter()
8289 .filter(|(_, entity)| entity.entity_type == "impl" && entity.name == "SemServer")
8290 .collect();
8291 assert!(
8292 sem_server_impls.len() >= 2,
8293 "test fixture should produce duplicate SemServer impl entities"
8294 );
8295 for (impl_id, _) in sem_server_impls {
8296 let impl_deps = graph.get_dependencies(impl_id);
8297 assert!(
8298 !impl_deps
8299 .iter()
8300 .any(|d| d.name == "build" && d.file_path == "graph.rs"),
8301 "impl container should not inherit child build calls. Deps: {:?}",
8302 impl_deps
8303 .iter()
8304 .map(|d| (&d.name, &d.file_path))
8305 .collect::<Vec<_>>()
8306 );
8307 }
8308
8309 let method_id = graph
8310 .entities
8311 .iter()
8312 .find(|(_, entity)| entity.name == "get_or_build_graph")
8313 .map(|(id, _)| id)
8314 .expect("get_or_build_graph entity should exist");
8315 let method_deps = graph.get_dependencies(method_id);
8316 assert!(
8317 method_deps
8318 .iter()
8319 .any(|d| d.name == "build" && d.file_path == "graph.rs"),
8320 "direct EntityGraph::build call should remain resolved. Deps: {:?}",
8321 method_deps
8322 .iter()
8323 .map(|d| (&d.name, &d.file_path))
8324 .collect::<Vec<_>>()
8325 );
8326 }
8327
8328 #[test]
8329 fn test_rust_lowercase_scoped_path_does_not_fallback_to_local_function() {
8330 let (dir, registry) = create_test_repo();
8331 let root = dir.path();
8332
8333 write_file(
8334 root,
8335 "main.rs",
8336 "\
8337fn baz() {}
8338
8339fn caller() {
8340 foo::bar::baz();
8341}
8342",
8343 );
8344
8345 let (graph, _) = EntityGraph::build(root, &["main.rs".into()], ®istry);
8346
8347 let caller_id = graph
8348 .entities
8349 .iter()
8350 .find(|(_, entity)| entity.name == "caller")
8351 .map(|(id, _)| id)
8352 .expect("caller entity should exist");
8353 let deps = graph.get_dependencies(caller_id);
8354 assert!(
8355 !deps.iter().any(|d| d.name == "baz"),
8356 "lowercase scoped path should not fall back to local baz function. Deps: {:?}",
8357 deps.iter()
8358 .map(|d| (&d.name, &d.file_path))
8359 .collect::<Vec<_>>()
8360 );
8361 }
8362
8363 #[test]
8364 fn test_dot_chain_no_false_edges() {
8365 let (dir, registry) = create_test_repo();
8366 let root = dir.path();
8367
8368 write_file(
8371 root,
8372 "a.py",
8373 "\
8374class ClassA:
8375 def run(self):
8376 return self.process()
8377
8378 def process(self):
8379 return 1
8380",
8381 );
8382 write_file(
8383 root,
8384 "b.py",
8385 "\
8386class ClassB:
8387 def process(self):
8388 return 2
8389",
8390 );
8391
8392 let (graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], ®istry);
8393
8394 let run_id = graph
8395 .entities
8396 .keys()
8397 .find(|id| id.contains("run"))
8398 .expect("run entity should exist");
8399 let deps = graph.get_dependencies(run_id);
8400 for dep in &deps {
8402 if dep.name == "process" {
8403 assert!(
8404 dep.file_path == "a.py",
8405 "run's process dep should be in a.py, not {}",
8406 dep.file_path
8407 );
8408 }
8409 }
8410 }
8411
8412 #[test]
8413 fn test_dot_chain_fallback() {
8414 let (dir, registry) = create_test_repo();
8415 let root = dir.path();
8416
8417 write_file(
8421 root,
8422 "app.ts",
8423 "\
8424export function helper() { return 1; }
8425export function caller() {
8426 const val = helper();
8427 return val;
8428}
8429",
8430 );
8431
8432 let (graph, _) = EntityGraph::build(root, &["app.ts".into()], ®istry);
8433
8434 let caller_id = graph
8435 .entities
8436 .keys()
8437 .find(|id| id.contains("caller"))
8438 .expect("caller entity should exist");
8439 let deps = graph.get_dependencies(caller_id);
8440 assert!(
8441 deps.iter().any(|d| d.name == "helper"),
8442 "caller should still resolve helper via bag-of-words. Deps: {:?}",
8443 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8444 );
8445 }
8446
8447 #[cfg(feature = "lang-clojure")]
8448 #[test]
8449 fn test_clojure_namespace_alias_resolution() {
8450 let (dir, registry) = create_test_repo();
8451 let root = dir.path();
8452
8453 write_file(
8455 root,
8456 "src/myapp/util.cljs",
8457 r#"(ns myapp.util)
8458
8459(defn vectorize-if-not-sequential [x]
8460 (if (sequential? x) x [x]))
8461"#,
8462 );
8463
8464 write_file(
8466 root,
8467 "src/myapp/elements.cljs",
8468 r#"(ns myapp.elements
8469 (:require [myapp.util :as u]))
8470
8471(defn render-items [items]
8472 (u/vectorize-if-not-sequential items))
8473"#,
8474 );
8475
8476 let file_paths = vec![
8477 "src/myapp/util.cljs".to_string(),
8478 "src/myapp/elements.cljs".to_string(),
8479 ];
8480 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8481
8482 let render_id = graph
8483 .entities
8484 .keys()
8485 .find(|id| id.contains("render-items"))
8486 .expect("render-items entity should exist");
8487
8488 let deps = graph.get_dependencies(render_id);
8489 assert!(
8490 deps.iter().any(|d| d.name == "vectorize-if-not-sequential"),
8491 "render-items should depend on vectorize-if-not-sequential via :as alias. Deps: {:?}",
8492 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8493 );
8494
8495 let util_fn_id = graph
8496 .entities
8497 .keys()
8498 .find(|id| id.contains("vectorize-if-not-sequential"))
8499 .expect("vectorize-if-not-sequential entity should exist");
8500
8501 let dependents = graph.get_dependents(util_fn_id);
8502 assert!(
8503 dependents.iter().any(|d| d.name == "render-items"),
8504 "vectorize-if-not-sequential should be depended on by render-items. Dependents: {:?}",
8505 dependents.iter().map(|d| &d.name).collect::<Vec<_>>()
8506 );
8507 }
8508
8509 #[cfg(feature = "lang-clojure")]
8510 #[test]
8511 fn test_clojure_refer_resolution() {
8512 let (dir, registry) = create_test_repo();
8513 let root = dir.path();
8514
8515 write_file(
8516 root,
8517 "src/myapp/strings.clj",
8518 r#"(ns myapp.strings)
8519
8520(defn capitalize-first [s]
8521 (str (clojure.string/upper-case (subs s 0 1)) (subs s 1)))
8522"#,
8523 );
8524
8525 write_file(
8526 root,
8527 "src/myapp/greeting.clj",
8528 r#"(ns myapp.greeting
8529 (:require [myapp.strings :refer [capitalize-first]]))
8530
8531(defn greet [name]
8532 (str "Hello, " (capitalize-first name) "!"))
8533"#,
8534 );
8535
8536 let file_paths = vec![
8537 "src/myapp/strings.clj".to_string(),
8538 "src/myapp/greeting.clj".to_string(),
8539 ];
8540 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8541
8542 let greet_id = graph
8543 .entities
8544 .iter()
8545 .find(|(_, e)| e.name == "greet")
8546 .map(|(id, _)| id)
8547 .expect("greet entity should exist");
8548
8549 let deps = graph.get_dependencies(greet_id);
8550 assert!(
8551 deps.iter().any(|d| d.name == "capitalize-first"),
8552 "greet should depend on capitalize-first via :refer. Deps: {:?}",
8553 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8554 );
8555 }
8556
8557 #[cfg(feature = "lang-clojure")]
8558 #[test]
8559 fn test_clojure_kebab_reference_tracking() {
8560 let (dir, registry) = create_test_repo();
8561 let root = dir.path();
8562
8563 write_file(
8564 root,
8565 "src/myapp/math.clj",
8566 r#"(ns myapp.math)
8567
8568(defn square-root-of [n]
8569 (Math/sqrt n))
8570"#,
8571 );
8572
8573 write_file(
8574 root,
8575 "src/myapp/stats.clj",
8576 r#"(ns myapp.stats
8577 (:require [myapp.math :refer [square-root-of]]))
8578
8579(defn std-deviation [xs]
8580 (square-root-of (/ (reduce + xs) (count xs))))
8581"#,
8582 );
8583
8584 let file_paths = vec![
8585 "src/myapp/math.clj".to_string(),
8586 "src/myapp/stats.clj".to_string(),
8587 ];
8588 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8589
8590 let std_dev_id = graph
8591 .entities
8592 .keys()
8593 .find(|id| id.contains("std-deviation"))
8594 .expect("std-deviation entity should exist");
8595
8596 let deps = graph.get_dependencies(std_dev_id);
8597 assert!(
8598 deps.iter().any(|d| d.name == "square-root-of"),
8599 "std-deviation should depend on square-root-of (kebab name via :refer). Deps: {:?}",
8600 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8601 );
8602 }
8603
8604 #[cfg(feature = "lang-clojure")]
8605 #[test]
8606 fn test_clojure_arithmetic_star_no_false_edge() {
8607 let (dir, registry) = create_test_repo();
8608 let root = dir.path();
8609
8610 write_file(
8613 root,
8614 "src/myapp/math.clj",
8615 r#"(ns myapp.math)
8616
8617(defn hypotenuse [a b]
8618 (Math/sqrt (+ (* a a) (* b b))))
8619"#,
8620 );
8621
8622 write_file(
8625 root,
8626 "src/myapp/other.clj",
8627 r#"(ns myapp.other)
8628
8629(defn * [x y] (* x y))
8630"#,
8631 );
8632
8633 let file_paths = vec![
8634 "src/myapp/math.clj".to_string(),
8635 "src/myapp/other.clj".to_string(),
8636 ];
8637 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8638
8639 let hyp_id = graph
8640 .entities
8641 .keys()
8642 .find(|id| id.contains("hypotenuse"))
8643 .expect("hypotenuse entity should exist");
8644
8645 let deps = graph.get_dependencies(hyp_id);
8646 assert!(
8647 !deps.iter().any(|d| d.name == "*"),
8648 "hypotenuse should not have a false '*' dependency from arithmetic use. Deps: {:?}",
8649 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8650 );
8651 }
8652
8653 #[test]
8654 #[cfg(feature = "lang-clojure")]
8655 fn test_clojure_gensym_does_not_blank_qualified_call() {
8656 let (dir, registry) = create_test_repo();
8660 let root = dir.path();
8661
8662 write_file(
8663 root,
8664 "src/myapp/rewrite.cljc",
8665 r#"(ns myapp.rewrite)
8666
8667(defn add-expected-value! [path line value]
8668 (str path line value))
8669"#,
8670 );
8671
8672 write_file(
8676 root,
8677 "src/myapp/core.cljc",
8678 r#"(ns myapp.core
8679 (:require [myapp.rewrite :as rewrite]))
8680
8681(defmacro snap! [path line]
8682 `(let [result# (rewrite/add-expected-value! ~path ~line :result)]
8683 result#))
8684"#,
8685 );
8686
8687 let file_paths = vec![
8688 "src/myapp/rewrite.cljc".to_string(),
8689 "src/myapp/core.cljc".to_string(),
8690 ];
8691 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8692
8693 let snap_id = graph
8694 .entities
8695 .iter()
8696 .find(|(_, e)| e.name == "snap!")
8697 .map(|(id, _)| id.clone())
8698 .expect("snap! macro entity should exist");
8699
8700 let deps = graph.get_dependencies(&snap_id);
8701 assert!(
8702 deps.iter().any(|d| d.name == "add-expected-value!"),
8703 "snap! should depend on add-expected-value! via rewrite/add-expected-value! alias call. Deps: {:?}",
8704 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8705 );
8706 }
8707
8708 #[test]
8709 #[cfg(feature = "lang-clojure")]
8710 fn test_clojure_reader_conditional_require_alias_resolved() {
8711 let (dir, registry) = create_test_repo();
8715 let root = dir.path();
8716
8717 write_file(
8718 root,
8719 "src/myapp/backend.clj",
8720 r#"(ns myapp.backend)
8721
8722(defn do-work! [x] x)
8723"#,
8724 );
8725
8726 write_file(
8727 root,
8728 "src/myapp/shared.cljc",
8729 r#"(ns myapp.shared
8730 (:require #?(:clj [myapp.backend :as backend])))
8731
8732(defn entry-point []
8733 (backend/do-work! 42))
8734"#,
8735 );
8736
8737 let file_paths = vec![
8738 "src/myapp/backend.clj".to_string(),
8739 "src/myapp/shared.cljc".to_string(),
8740 ];
8741 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8742
8743 let entry_id = graph
8744 .entities
8745 .iter()
8746 .find(|(_, e)| e.name == "entry-point")
8747 .map(|(id, _)| id.clone())
8748 .expect("entry-point entity should exist");
8749
8750 let deps = graph.get_dependencies(&entry_id);
8751 assert!(
8752 deps.iter().any(|d| d.name == "do-work!"),
8753 "entry-point should depend on do-work! via reader-conditional alias. Deps: {:?}",
8754 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8755 );
8756 }
8757
8758 #[test]
8759 #[cfg(feature = "lang-clojure")]
8760 fn test_clojure_multiline_require_refer_resolution() {
8761 let (dir, registry) = create_test_repo();
8762 let root = dir.path();
8763
8764 write_file(
8765 root,
8766 "src/myapp/strings.clj",
8767 r#"(ns myapp.strings)
8768
8769(defn capitalize-first [s]
8770 (str (clojure.string/upper-case (subs s 0 1)) (subs s 1)))
8771"#,
8772 );
8773
8774 write_file(
8776 root,
8777 "src/myapp/greeting.clj",
8778 r#"(ns myapp.greeting
8779 (:require
8780 [myapp.strings
8781 :refer [capitalize-first]]))
8782
8783(defn greet [name]
8784 (str "Hello, " (capitalize-first name) "!"))
8785"#,
8786 );
8787
8788 let file_paths = vec![
8789 "src/myapp/strings.clj".to_string(),
8790 "src/myapp/greeting.clj".to_string(),
8791 ];
8792 let (graph, _) = EntityGraph::build(root, &file_paths, ®istry);
8793
8794 let greet_id = graph
8795 .entities
8796 .iter()
8797 .find(|(_, e)| e.name == "greet")
8798 .map(|(id, _)| id)
8799 .expect("greet entity should exist");
8800
8801 let deps = graph.get_dependencies(greet_id);
8802 assert!(
8803 deps.iter().any(|d| d.name == "capitalize-first"),
8804 "greet should depend on capitalize-first via multi-line :refer. Deps: {:?}",
8805 deps.iter().map(|d| &d.name).collect::<Vec<_>>()
8806 );
8807 }
8808
8809 fn make_entity(name: &str, file_path: &str, content: &str) -> SemanticEntity {
8812 SemanticEntity {
8813 id: format!("{}::function::{}", file_path, name),
8814 name: name.to_string(),
8815 entity_type: "function".to_string(),
8816 file_path: file_path.to_string(),
8817 start_line: 1,
8818 end_line: 5,
8819 content: content.to_string(),
8820 content_hash: String::new(),
8821 structural_hash: None,
8822 parent_id: None,
8823 metadata: None,
8824 }
8825 }
8826
8827 #[test]
8828 fn test_entity_detected_by_name_pattern() {
8829 let entity = make_entity("test_login", "src/auth.py", "def test_login(): pass");
8830 assert!(is_test_entity(&entity, &[]));
8831 }
8832
8833 #[test]
8834 fn test_entity_detected_by_path_and_content_marker() {
8835 let entity = make_entity("run", "e2e-tests/login.ts", "describe('login', () => { it('works', () => {}) })");
8837 assert!(is_test_entity(&entity, &[]));
8838 }
8839
8840 #[test]
8841 fn test_entity_not_detected_in_production_code() {
8842 let entity = make_entity("handle_request", "src/server.rs", "fn handle_request() {}");
8843 assert!(!is_test_entity(&entity, &[]));
8844 }
8845
8846 #[test]
8847 fn test_entity_path_match_without_content_marker_not_detected() {
8848 let entity = make_entity("helper", "tests/helpers.py", "def helper(): return 42");
8850 assert!(!is_test_entity(&entity, &[]));
8851 }
8852
8853 #[test]
8854 fn test_entity_detected_in_hyphenated_test_dir() {
8855 let entity = make_entity("check", "integration-tests/api.py", "@pytest.mark.slow\ndef check(): pass");
8856 assert!(is_test_entity(&entity, &[]));
8857 }
8858
8859 #[test]
8860 fn test_entity_detected_in_dunder_tests_dir() {
8861 let entity = make_entity("render", "__tests__/Button.test.tsx", "test('renders', () => {})");
8862 assert!(is_test_entity(&entity, &[]));
8863 }
8864
8865 #[test]
8866 fn test_entity_detected_with_custom_dir() {
8867 let entity = make_entity("verify", "qa/smoke.py", "@pytest.fixture\ndef verify(): pass");
8868 assert!(!is_test_entity(&entity, &[]));
8870 let custom = vec!["qa".to_string()];
8872 assert!(is_test_entity(&entity, &custom));
8873 }
8874
8875 #[test]
8876 fn test_entity_contest_dir_not_false_positive() {
8877 let entity = make_entity("solve", "contest/problem_a.py", "def solve(): test('input')");
8878 assert!(!is_test_entity(&entity, &[]));
8879 }
8880
8881 #[test]
8882 fn filter_test_entities_with_custom_dirs_includes_custom_matches() {
8883 let entities = vec![
8884 make_entity("test_a", "src/lib.rs", "#[test]\nfn test_a() {}"),
8885 make_entity("run", "qa/smoke.rs", "#[test]\nfn run() {}"),
8886 make_entity("main", "src/main.rs", "fn main() {}"),
8887 ];
8888 let entity_map: std::collections::HashMap<String, EntityInfo> = entities
8889 .iter()
8890 .map(|e| {
8891 (
8892 e.id.clone(),
8893 EntityInfo {
8894 id: e.id.clone(),
8895 name: e.name.clone(),
8896 entity_type: e.entity_type.clone(),
8897 file_path: e.file_path.clone(),
8898 parent_id: None,
8899 start_line: e.start_line,
8900 end_line: e.end_line,
8901 },
8902 )
8903 })
8904 .collect();
8905 let graph = EntityGraph::from_parts(entity_map, vec![]);
8906
8907 let builtin = graph.filter_test_entities(&entities);
8908 assert!(builtin.contains("src/lib.rs::function::test_a"));
8909 assert!(!builtin.contains("qa/smoke.rs::function::run"));
8910
8911 let custom = vec!["qa".to_string()];
8912 let with_custom = graph.filter_test_entities_with_custom_dirs(&entities, &custom);
8913 assert!(with_custom.contains("src/lib.rs::function::test_a"));
8914 assert!(with_custom.contains("qa/smoke.rs::function::run"));
8915 assert!(!with_custom.contains("src/main.rs::function::main"));
8916 }
8917}