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