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