Skip to main content

codelens_engine/file_ops/
mod.rs

1mod reader;
2mod writer;
3
4use crate::project::ProjectRoot;
5use anyhow::{Context, Result, bail};
6use globset::{Glob, GlobMatcher};
7use serde::Serialize;
8use std::fs;
9use std::path::Path;
10
11// Re-export reader functions
12pub use reader::{find_files, list_dir, read_file, search_for_pattern, search_for_pattern_smart};
13
14// Re-export writer functions
15pub use writer::{
16    create_text_file, delete_lines, insert_after_symbol, insert_at_line, insert_before_symbol,
17    replace_content, replace_lines, replace_symbol_body,
18};
19
20#[derive(Debug, Clone, Serialize)]
21pub struct FileReadResult {
22    pub file_path: String,
23    pub total_lines: usize,
24    pub content: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DirectoryEntry {
29    pub name: String,
30    pub entry_type: String,
31    pub path: String,
32    pub size: Option<u64>,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct FileMatch {
37    pub path: String,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct PatternMatch {
42    pub file_path: String,
43    pub line: usize,
44    pub column: usize,
45    pub matched_text: String,
46    pub line_content: String,
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    pub context_before: Vec<String>,
49    #[serde(skip_serializing_if = "Vec::is_empty")]
50    pub context_after: Vec<String>,
51}
52
53/// Pattern match enriched with enclosing symbol context (Smart Excerpt).
54#[derive(Debug, Clone, Serialize)]
55pub struct SmartPatternMatch {
56    pub file_path: String,
57    pub line: usize,
58    pub column: usize,
59    pub matched_text: String,
60    pub line_content: String,
61    #[serde(skip_serializing_if = "Vec::is_empty")]
62    pub context_before: Vec<String>,
63    #[serde(skip_serializing_if = "Vec::is_empty")]
64    pub context_after: Vec<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub enclosing_symbol: Option<EnclosingSymbol>,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct EnclosingSymbol {
71    pub name: String,
72    pub kind: String,
73    pub name_path: String,
74    pub start_line: usize,
75    pub end_line: usize,
76    pub signature: String,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct TextReference {
81    pub file_path: String,
82    pub line: usize,
83    pub column: usize,
84    pub line_content: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub enclosing_symbol: Option<EnclosingSymbol>,
87    pub is_declaration: bool,
88    /// Lines immediately preceding `line_content` (closest line last).
89    /// Populated by `find_referencing_symbols_via_text` so reviewers
90    /// can read the callsite in its local scope without a follow-up
91    /// Read — matches Serena's `content_around_reference` window.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub context_before: Vec<String>,
94    /// Lines immediately following `line_content` (closest line first).
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub context_after: Vec<String>,
97}
98
99/// Outcome of a text-based reference scan: the returned references plus
100/// the files that were suppressed because they re-declare the symbol
101/// (shadow-file suppression). The MCP layer surfaces the suppressed
102/// list via a `shadow_suppression` LimitsApplied entry.
103#[derive(Debug, Clone)]
104pub struct TextRefsReport {
105    pub references: Vec<TextReference>,
106    pub shadow_files_suppressed: Vec<String>,
107}
108
109// --- Helper structs and functions (pub(super) for use within file_ops) ---
110
111pub(super) struct FlatSymbol {
112    pub(super) name: String,
113    pub(super) kind: String,
114    pub(super) name_path: String,
115    pub(super) start_line: usize,
116    pub(super) end_line: usize,
117    pub(super) signature: String,
118}
119
120pub(super) fn flatten_to_ranges(symbols: &[crate::symbols::SymbolInfo]) -> Vec<FlatSymbol> {
121    let mut flat = Vec::new();
122    for s in symbols {
123        let end_line = estimate_end_line(s);
124        if matches!(
125            s.kind,
126            crate::symbols::SymbolKind::Function
127                | crate::symbols::SymbolKind::Method
128                | crate::symbols::SymbolKind::Class
129                | crate::symbols::SymbolKind::Interface
130                | crate::symbols::SymbolKind::Module
131        ) {
132            flat.push(FlatSymbol {
133                name: s.name.clone(),
134                kind: s.kind.as_label().to_owned(),
135                name_path: s.name_path.clone(),
136                start_line: s.line,
137                end_line,
138                signature: s.signature.clone(),
139            });
140        }
141        flat.extend(flatten_to_ranges(&s.children));
142    }
143    flat
144}
145
146fn estimate_end_line(symbol: &crate::symbols::SymbolInfo) -> usize {
147    // Phase 2-2: prefer the tree-sitter-accurate `end_line` populated
148    // at parse time (Migration 7). Without this preference, container
149    // resolution for a call site inside `impl Type { fn foo() { … } }`
150    // fell back to the `line + 10` heuristic, which under-estimated
151    // the enclosing method and caused `find_enclosing_symbol` to
152    // return `None` for any reference past the tenth line of its
153    // container — the exact behavior seen in the Serena A/B test.
154    if symbol.end_line > symbol.line {
155        return symbol.end_line;
156    }
157    if let Some(body) = &symbol.body {
158        symbol.line + body.lines().count()
159    } else if !symbol.children.is_empty() {
160        symbol
161            .children
162            .iter()
163            .map(estimate_end_line)
164            .max()
165            .unwrap_or(symbol.line + 10)
166    } else {
167        symbol.line + 10 // heuristic: assume ~10 lines per symbol
168    }
169}
170
171pub(super) fn find_enclosing_symbol(
172    symbols: &[FlatSymbol],
173    line: usize,
174) -> Option<EnclosingSymbol> {
175    symbols
176        .iter()
177        .filter(|s| s.start_line <= line && line <= s.end_line)
178        .min_by_key(|s| s.end_line - s.start_line)
179        .map(|s| EnclosingSymbol {
180            name: s.name.clone(),
181            kind: s.kind.clone(),
182            name_path: s.name_path.clone(),
183            start_line: s.start_line,
184            end_line: s.end_line,
185            signature: s.signature.clone(),
186        })
187}
188
189pub(super) fn to_directory_entry(project: &ProjectRoot, path: &Path) -> Result<DirectoryEntry> {
190    let metadata = fs::metadata(path)?;
191    Ok(DirectoryEntry {
192        name: path
193            .file_name()
194            .map(|name| name.to_string_lossy().into_owned())
195            .unwrap_or_default(),
196        entry_type: if metadata.is_dir() {
197            "directory".to_owned()
198        } else {
199            "file".to_owned()
200        },
201        path: project.to_relative(path),
202        size: if metadata.is_file() {
203            Some(metadata.len())
204        } else {
205            None
206        },
207    })
208}
209
210pub(super) fn compile_glob(pattern: &str) -> Result<GlobMatcher> {
211    Glob::new(pattern)
212        .with_context(|| format!("invalid glob: {pattern}"))
213        .map(|glob| glob.compile_matcher())
214}
215
216// --- Public functions that stay in mod.rs ---
217
218/// Find references to a symbol via text-based search (no LSP required).
219/// Optionally exclude the declaration file and filter out shadowing files.
220pub fn find_referencing_symbols_via_text(
221    project: &ProjectRoot,
222    symbol_name: &str,
223    declaration_file: Option<&str>,
224    max_results: usize,
225) -> Result<TextRefsReport> {
226    use crate::rename::find_all_word_matches;
227    use crate::symbols::get_symbols_overview;
228
229    let all_matches = find_all_word_matches(project, symbol_name)?;
230
231    let shadow_files =
232        find_shadowing_files_for_refs(project, declaration_file, symbol_name, &all_matches)?;
233
234    let mut symbol_cache: std::collections::HashMap<String, Vec<FlatSymbol>> =
235        std::collections::HashMap::new();
236
237    let mut results = Vec::new();
238    for (file_path, line, column) in &all_matches {
239        if results.len() >= max_results {
240            break;
241        }
242        if let Some(decl) = declaration_file
243            && file_path != decl
244            && shadow_files.contains(file_path)
245        {
246            continue;
247        }
248
249        let (context_before, line_content, context_after) =
250            read_line_window(project, file_path, *line, 2, 2)
251                .unwrap_or_else(|_| (Vec::new(), String::new(), Vec::new()));
252
253        if !symbol_cache.contains_key(file_path)
254            && let Ok(symbols) = get_symbols_overview(project, file_path, 3)
255        {
256            symbol_cache.insert(file_path.clone(), flatten_to_ranges(&symbols));
257        }
258        let enclosing = symbol_cache
259            .get(file_path)
260            .and_then(|symbols| find_enclosing_symbol(symbols, *line));
261
262        let is_declaration = enclosing
263            .as_ref()
264            .map(|e| e.name == symbol_name && e.start_line == *line)
265            .unwrap_or(false);
266
267        results.push(TextReference {
268            file_path: file_path.clone(),
269            line: *line,
270            column: *column,
271            line_content,
272            enclosing_symbol: enclosing,
273            is_declaration,
274            context_before,
275            context_after,
276        });
277    }
278
279    let mut shadow_files_sorted: Vec<String> = shadow_files.into_iter().collect();
280    shadow_files_sorted.sort();
281
282    Ok(TextRefsReport {
283        references: results,
284        shadow_files_suppressed: shadow_files_sorted,
285    })
286}
287
288/// Extract the word (identifier) at a given line/column position in a file.
289pub fn extract_word_at_position(
290    project: &ProjectRoot,
291    file_path: &str,
292    line: usize,
293    column: usize,
294) -> Result<String> {
295    let resolved = project.resolve(file_path)?;
296    let content = fs::read_to_string(&resolved)?;
297    let lines: Vec<&str> = content.lines().collect();
298    let line_idx = line.saturating_sub(1);
299    if line_idx >= lines.len() {
300        bail!(
301            "line {} out of range (file has {} lines)",
302            line,
303            lines.len()
304        );
305    }
306    let line_str = lines[line_idx];
307    let col_idx = column.saturating_sub(1);
308    if col_idx >= line_str.len() {
309        bail!(
310            "column {} out of range (line has {} chars)",
311            column,
312            line_str.len()
313        );
314    }
315
316    let bytes = line_str.as_bytes();
317    let mut start = col_idx;
318    while start > 0 && is_ident_char(bytes[start - 1]) {
319        start -= 1;
320    }
321    let mut end = col_idx;
322    while end < bytes.len() && is_ident_char(bytes[end]) {
323        end += 1;
324    }
325    if start == end {
326        bail!("no identifier at {}:{}", line, column);
327    }
328    Ok(line_str[start..end].to_string())
329}
330
331fn is_ident_char(b: u8) -> bool {
332    b.is_ascii_alphanumeric() || b == b'_'
333}
334
335/// Read `n_before` lines preceding `line`, the line itself, and
336/// `n_after` lines following it (each tuple element collected in
337/// source order). Used by `find_referencing_symbols_via_text` to
338/// build the Serena-parity `content_around_reference` window so a
339/// reviewer can read a callsite in its local scope without a
340/// follow-up Read.
341fn read_line_window(
342    project: &ProjectRoot,
343    file_path: &str,
344    line: usize,
345    n_before: usize,
346    n_after: usize,
347) -> Result<(Vec<String>, String, Vec<String>)> {
348    let resolved = project.resolve(file_path)?;
349    let content = fs::read_to_string(&resolved)?;
350    let all_lines: Vec<&str> = content.lines().collect();
351    if line == 0 || line > all_lines.len() {
352        return Err(anyhow::anyhow!("line {} out of range", line));
353    }
354    let idx = line - 1;
355    let before_start = idx.saturating_sub(n_before);
356    let after_end = (idx + 1 + n_after).min(all_lines.len());
357    let before: Vec<String> = all_lines[before_start..idx].iter().map(|s| s.to_string()).collect();
358    let current = all_lines[idx].to_string();
359    let after: Vec<String> = all_lines[idx + 1..after_end].iter().map(|s| s.to_string()).collect();
360    Ok((before, current, after))
361}
362
363fn find_shadowing_files_for_refs(
364    project: &ProjectRoot,
365    declaration_file: Option<&str>,
366    symbol_name: &str,
367    all_matches: &[(String, usize, usize)],
368) -> Result<std::collections::HashSet<String>> {
369    use crate::symbols::get_symbols_overview;
370
371    let mut shadow_files = std::collections::HashSet::new();
372    let files_with_matches: std::collections::HashSet<&String> =
373        all_matches.iter().map(|(f, _, _)| f).collect();
374
375    for fp in files_with_matches {
376        if declaration_file.map(|d| d == fp).unwrap_or(false) {
377            continue;
378        }
379        if let Ok(symbols) = get_symbols_overview(project, fp, 3)
380            && has_declaration_recursive(&symbols, symbol_name)
381        {
382            shadow_files.insert(fp.clone());
383        }
384    }
385    Ok(shadow_files)
386}
387
388fn has_declaration_recursive(symbols: &[crate::symbols::SymbolInfo], name: &str) -> bool {
389    symbols
390        .iter()
391        .any(|s| s.name == name || has_declaration_recursive(&s.children, name))
392}
393
394#[cfg(test)]
395mod tests {
396    use super::{find_files, list_dir, read_file, search_for_pattern};
397    use crate::ProjectRoot;
398    use std::fs;
399
400    #[test]
401    fn reads_partial_file() {
402        let root = fixture_root();
403        let project = ProjectRoot::new(&root).expect("project");
404        let result = read_file(&project, "src/main.py", Some(1), Some(3)).expect("read file");
405        assert_eq!(result.total_lines, 4);
406        assert_eq!(
407            result.content,
408            "def greet(name):\n    return f\"Hello {name}\""
409        );
410    }
411
412    #[test]
413    fn lists_nested_dir() {
414        let root = fixture_root();
415        let project = ProjectRoot::new(&root).expect("project");
416        let result = list_dir(&project, ".", true).expect("list dir");
417        assert!(result.iter().any(|entry| entry.path == "src/main.py"));
418    }
419
420    #[test]
421    fn finds_files_by_glob() {
422        let root = fixture_root();
423        let project = ProjectRoot::new(&root).expect("project");
424        let result = find_files(&project, "*.py", Some("src")).expect("find files");
425        assert_eq!(result.len(), 1);
426        assert_eq!(result[0].path, "src/main.py");
427    }
428
429    #[test]
430    fn searches_text_pattern() {
431        let root = fixture_root();
432        let project = ProjectRoot::new(&root).expect("project");
433        let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 0, 0).expect("search");
434        assert_eq!(result.len(), 2);
435        assert_eq!(result[0].file_path, "src/main.py");
436        assert!(result[0].context_before.is_empty());
437        assert!(result[0].context_after.is_empty());
438    }
439
440    #[test]
441    fn search_with_zero_context() {
442        let root = fixture_root();
443        let project = ProjectRoot::new(&root).expect("project");
444        let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 0, 0).expect("search");
445        for m in &result {
446            assert!(m.context_before.is_empty());
447            assert!(m.context_after.is_empty());
448        }
449    }
450
451    #[test]
452    fn search_with_symmetric_context() {
453        let root = fixture_root();
454        let project = ProjectRoot::new(&root).expect("project");
455        let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 1, 1).expect("search");
456        assert_eq!(result.len(), 2);
457        assert_eq!(result[0].line, 2);
458        assert_eq!(result[0].context_before.len(), 1);
459        assert_eq!(result[0].context_before[0], "class Service:");
460        assert_eq!(result[0].context_after.len(), 1);
461        assert!(result[0].context_after[0].contains("return"));
462        assert_eq!(result[1].line, 4);
463        assert_eq!(result[1].context_before.len(), 1);
464        assert!(result[1].context_after.is_empty());
465    }
466
467    #[test]
468    fn search_context_at_file_start() {
469        let root = fixture_root();
470        let project = ProjectRoot::new(&root).expect("project");
471        let result = search_for_pattern(&project, "class", Some("*.py"), 10, 3, 1).expect("search");
472        assert_eq!(result.len(), 1);
473        assert_eq!(result[0].line, 1);
474        assert!(result[0].context_before.is_empty());
475        assert_eq!(result[0].context_after.len(), 1);
476    }
477
478    #[test]
479    fn search_context_at_file_end() {
480        let root = fixture_root();
481        let project = ProjectRoot::new(&root).expect("project");
482        let result = search_for_pattern(&project, "print", Some("*.py"), 10, 2, 3).expect("search");
483        assert_eq!(result.len(), 1);
484        assert_eq!(result[0].line, 4);
485        assert_eq!(result[0].context_before.len(), 2);
486        assert!(result[0].context_after.is_empty());
487    }
488
489    #[test]
490    fn search_asymmetric_context() {
491        let root = fixture_root();
492        let project = ProjectRoot::new(&root).expect("project");
493        let result =
494            search_for_pattern(&project, "return", Some("*.py"), 10, 2, 1).expect("search");
495        assert_eq!(result.len(), 1);
496        assert_eq!(result[0].line, 3);
497        assert_eq!(result[0].context_before.len(), 2);
498        assert_eq!(result[0].context_after.len(), 1);
499    }
500
501    #[test]
502    fn search_context_serialization() {
503        let m_empty = super::PatternMatch {
504            file_path: "test.py".to_string(),
505            line: 1,
506            column: 1,
507            matched_text: "foo".to_string(),
508            line_content: "foo bar".to_string(),
509            context_before: vec![],
510            context_after: vec![],
511        };
512        let json_empty = serde_json::to_string(&m_empty).expect("serialize");
513        assert!(!json_empty.contains("context_before"));
514        assert!(!json_empty.contains("context_after"));
515
516        let m_with = super::PatternMatch {
517            file_path: "test.py".to_string(),
518            line: 2,
519            column: 1,
520            matched_text: "foo".to_string(),
521            line_content: "foo bar".to_string(),
522            context_before: vec!["line above".to_string()],
523            context_after: vec!["line below".to_string()],
524        };
525        let json_with = serde_json::to_string(&m_with).expect("serialize");
526        assert!(json_with.contains("context_before"));
527        assert!(json_with.contains("context_after"));
528    }
529
530    #[test]
531    fn text_reference_finds_all_occurrences() {
532        let root = fixture_root();
533        let project = ProjectRoot::new(&root).expect("project");
534        let report = super::find_referencing_symbols_via_text(&project, "greet", None, 100)
535            .expect("text refs");
536        let refs = &report.references;
537        assert_eq!(refs.len(), 2); // "def greet" + "print(greet(...))"
538        assert!(refs.iter().all(|r| r.file_path == "src/main.py"));
539        assert!(refs.iter().all(|r| !r.line_content.is_empty()));
540    }
541
542    #[test]
543    fn text_reference_with_declaration_file() {
544        let dir = ref_fixture_root();
545        let project = ProjectRoot::new(&dir).expect("project");
546        let report =
547            super::find_referencing_symbols_via_text(&project, "helper", Some("src/utils.py"), 100)
548                .expect("text refs");
549        assert!(report.references.len() >= 2);
550    }
551
552    #[test]
553    fn text_reference_shadowing_excluded() {
554        let dir = ref_fixture_root();
555        let project = ProjectRoot::new(&dir).expect("project");
556        let report =
557            super::find_referencing_symbols_via_text(&project, "run", Some("src/service.py"), 100)
558                .expect("text refs");
559        assert!(
560            report.references.iter().all(|r| r.file_path != "src/other.py"),
561            "should exclude other.py (has own 'run' declaration)"
562        );
563    }
564
565    #[test]
566    fn text_reference_resolves_rust_impl_method_as_enclosing() {
567        // Phase 2-2: the Serena A/B benchmark exposed that a call
568        // inside a Rust `impl Type { fn method() { call() } }` was
569        // returning `enclosing_symbol: None` because `estimate_end_line`
570        // fell back to a `line + 10` heuristic (ignoring the tree-sitter
571        // `end_line` populated by Migration 7). Once the method's
572        // span exceeded 10 lines, `find_enclosing_symbol` silently
573        // dropped it. Lock the correct resolution path in.
574        let dir = std::env::temp_dir().join(format!(
575            "codelens-impl-enclosing-{}",
576            std::time::SystemTime::now()
577                .duration_since(std::time::UNIX_EPOCH)
578                .expect("time")
579                .as_nanos()
580        ));
581        fs::create_dir_all(&dir).expect("create dir");
582        fs::write(
583            dir.join("lib.rs"),
584            "pub fn helper() -> usize { 1 }\n\
585             pub struct Widget;\n\
586             impl Widget {\n\
587             \x20   pub fn run(&self) -> usize {\n\
588             \x20       // intentionally long so the 10-line heuristic would miss it\n\
589             \x20       let _a = 1;\n\
590             \x20       let _b = 2;\n\
591             \x20       let _c = 3;\n\
592             \x20       let _d = 4;\n\
593             \x20       let _e = 5;\n\
594             \x20       let _f = 6;\n\
595             \x20       let _g = 7;\n\
596             \x20       let _h = 8;\n\
597             \x20       let _i = 9;\n\
598             \x20       let _j = 10;\n\
599             \x20       helper()\n\
600             \x20   }\n\
601             }\n",
602        )
603        .expect("write rust");
604        let project = ProjectRoot::new(&dir).expect("project");
605        let report = super::find_referencing_symbols_via_text(&project, "helper", None, 100)
606            .expect("text refs");
607        let call_site = report
608            .references
609            .iter()
610            .find(|r| !r.is_declaration)
611            .expect("should find call site reference");
612        let enclosing = call_site
613            .enclosing_symbol
614            .as_ref()
615            .expect("call site inside impl Widget::run must resolve to an enclosing symbol");
616        assert!(
617            enclosing.name_path.contains("run"),
618            "enclosing symbol should be the `run` method; got {enclosing:?}"
619        );
620    }
621
622    #[test]
623    fn extract_word_at_position_works() {
624        let root = fixture_root();
625        let project = ProjectRoot::new(&root).expect("project");
626        let word = super::extract_word_at_position(&project, "src/main.py", 2, 5).expect("word");
627        assert_eq!(word, "greet");
628        let word2 = super::extract_word_at_position(&project, "src/main.py", 2, 11).expect("word");
629        assert_eq!(word2, "name");
630    }
631
632    fn ref_fixture_root() -> std::path::PathBuf {
633        let dir = std::env::temp_dir().join(format!(
634            "codelens-ref-fixture-{}",
635            std::time::SystemTime::now()
636                .duration_since(std::time::UNIX_EPOCH)
637                .expect("time")
638                .as_nanos()
639        ));
640        fs::create_dir_all(dir.join("src")).expect("create src dir");
641        fs::write(dir.join("src/utils.py"), "def helper():\n    return True\n")
642            .expect("write utils");
643        fs::write(
644            dir.join("src/main.py"),
645            "from utils import helper\n\nresult = helper()\n",
646        )
647        .expect("write main");
648        fs::write(
649            dir.join("src/service.py"),
650            "class Service:\n    def run(self):\n        return True\n",
651        )
652        .expect("write service");
653        fs::write(
654            dir.join("src/other.py"),
655            "class Other:\n    def run(self):\n        return False\n",
656        )
657        .expect("write other");
658        dir
659    }
660
661    fn fixture_root() -> std::path::PathBuf {
662        let dir = std::env::temp_dir().join(format!(
663            "codelens-core-fixture-{}",
664            std::time::SystemTime::now()
665                .duration_since(std::time::UNIX_EPOCH)
666                .expect("time")
667                .as_nanos()
668        ));
669        fs::create_dir_all(dir.join("src")).expect("create src dir");
670        fs::write(
671            dir.join("src/main.py"),
672            "class Service:\ndef greet(name):\n    return f\"Hello {name}\"\nprint(greet(\"A\"))\n",
673        )
674        .expect("write fixture");
675        dir
676    }
677
678    #[test]
679    fn text_refs_report_exposes_shadow_suppression_count() {
680        use crate::file_ops::find_referencing_symbols_via_text;
681        use std::fs;
682
683        let dir = tempfile::tempdir().expect("tempdir");
684        let root = dir.path();
685        fs::write(root.join("decl.py"), "class Target:\n    pass\n").unwrap();
686        fs::write(
687            root.join("shadow.py"),
688            "class Target:\n    pass\n# Target\n",
689        )
690        .unwrap();
691        fs::write(root.join("use.py"), "from decl import Target\nTarget()\n").unwrap();
692
693        let project = crate::ProjectRoot::new(root).expect("project");
694        let report =
695            find_referencing_symbols_via_text(&project, "Target", Some("decl.py"), 50).unwrap();
696
697        assert!(
698            report.shadow_files_suppressed.iter().any(|f| f == "shadow.py"),
699            "shadow.py should be suppressed, got: {:?}",
700            report.shadow_files_suppressed
701        );
702        assert!(
703            report.references.iter().all(|r| r.file_path != "shadow.py"),
704            "no reference should come from the suppressed file"
705        );
706    }
707}