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