opys_engine/file_refs.rs
1//! Scan source files for textual references to document ids.
2//!
3//! A document id like `FEAT-0123` is often mentioned in code — in comments,
4//! string literals, identifiers. The project config (`[file_refs]`) declares the
5//! textual *formats* an id may take (`{id}`, `{prefix}{num}`, `{prefix_lower}_{num}`,
6//! …); this module renders each format for a set of ids, scans the configured
7//! `roots`, and reports every hit with enough context to display it or to build a
8//! `sed` fix command.
9//!
10//! This is deliberately separate from [`crate::refs`] (the `references` relation
11//! maps *between documents*) — here the target is arbitrary source code.
12
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15
16use walkdir::{DirEntry, WalkDir};
17
18use crate::project::Project;
19
20/// One occurrence of a document id in a source file.
21pub struct FileRef {
22 /// The document id that matched (the canonical `PREFIX-NNNN`).
23 pub id: String,
24 /// The matched file, relative to the project root.
25 pub path: PathBuf,
26 /// 1-based line number.
27 pub line: usize,
28 /// The full matched line, trimmed of surrounding whitespace.
29 pub text: String,
30 /// The exact substring that matched (the rendered form of the id).
31 pub matched: String,
32 /// The format template that produced the match (see [`RefFormat`]).
33 pub template: String,
34 /// Whether the matching format required word boundaries.
35 pub word: bool,
36}
37
38/// Render a [`RefFormat`] template for `id`, or `None` if `id` is malformed.
39/// Placeholders: `{id}`, `{prefix}`, `{prefix_lower}`, `{num}`, `{padded}`.
40pub fn render(template: &str, id: &str, pad: usize) -> Option<String> {
41 let (prefix, numstr) = id.rsplit_once('-')?;
42 let num: u64 = numstr.parse().ok()?;
43 Some(
44 template
45 .replace("{prefix_lower}", &prefix.to_lowercase())
46 .replace("{prefix}", prefix)
47 .replace("{padded}", &format!("{num:0pad$}"))
48 .replace("{num}", &num.to_string())
49 .replace("{id}", id),
50 )
51}
52
53/// Build the search regex for a rendered form: the literal text, optionally
54/// wrapped in word boundaries.
55fn search_re(rendered: &str, word: bool) -> Option<regex::Regex> {
56 let escaped = regex::escape(rendered);
57 let pat = if word {
58 format!(r"\b{escaped}\b")
59 } else {
60 escaped
61 };
62 regex::Regex::new(&pat).ok()
63}
64
65/// Scan the configured `roots` for textual references to any of `ids`. Returns
66/// hits sorted by (path, line, id). Each id is matched in every effective format.
67pub fn scan(prj: &Project, ids: &[&str]) -> Vec<FileRef> {
68 let pad = prj.pcfg.pad;
69 let formats = prj.pcfg.file_refs.effective_formats();
70
71 // Precompute (id, rendered text, regex, template, word) for every id × format.
72 struct Needle {
73 id: String,
74 rendered: String,
75 re: regex::Regex,
76 template: String,
77 word: bool,
78 }
79 let mut needles: Vec<Needle> = Vec::new();
80 for id in ids {
81 for f in &formats {
82 let Some(rendered) = render(&f.template, id, pad) else {
83 continue;
84 };
85 let Some(re) = search_re(&rendered, f.word) else {
86 continue;
87 };
88 needles.push(Needle {
89 id: id.to_string(),
90 rendered,
91 re,
92 template: f.template.clone(),
93 word: f.word,
94 });
95 }
96 }
97 if needles.is_empty() {
98 return Vec::new();
99 }
100
101 let mut hits: Vec<FileRef> = Vec::new();
102 for abs in list_files(prj, &prj.pcfg.file_refs.roots) {
103 let Ok(bytes) = std::fs::read(&abs) else {
104 continue;
105 };
106 // Skip files that aren't valid UTF-8 (binaries) rather than lossily
107 // scanning them.
108 let Ok(content) = std::str::from_utf8(&bytes) else {
109 continue;
110 };
111 let rel = abs.strip_prefix(&prj.root).unwrap_or(&abs).to_path_buf();
112 for (i, line) in content.lines().enumerate() {
113 for n in &needles {
114 if !line.contains(&n.rendered) {
115 continue; // cheap pre-filter before the regex
116 }
117 if n.re.is_match(line) {
118 hits.push(FileRef {
119 id: n.id.clone(),
120 path: rel.clone(),
121 line: i + 1,
122 text: line.trim().to_string(),
123 matched: n.rendered.clone(),
124 template: n.template.clone(),
125 word: n.word,
126 });
127 }
128 }
129 }
130 }
131
132 hits.sort_by(|a, b| {
133 a.path
134 .cmp(&b.path)
135 .then(a.line.cmp(&b.line))
136 .then(a.id.cmp(&b.id))
137 });
138 hits
139}
140
141/// A `sed -i` command that rewrites every occurrence of one id's rendered form to
142/// the new id's rendered form in `path`. `word` mirrors the format so the
143/// substitution matches exactly what the scan found.
144pub fn sed_fix(rel_path: &Path, old: &str, new: &str, word: bool) -> String {
145 // Ids are `[A-Za-z0-9_-]`, none of which are special to a `/`-delimited `sed`
146 // s/// command, so no escaping is needed. Word boundaries map to GNU sed `\b`.
147 let pat = if word {
148 format!(r"\b{old}\b")
149 } else {
150 old.to_string()
151 };
152 format!("sed -i 's/{pat}/{new}/g' {}", rel_path.display())
153}
154
155/// Enumerate the files to scan under `roots` (project-root relative), skipping
156/// the inventory base, `.git`, `target`, `node_modules`, and hidden directories.
157fn list_files(prj: &Project, roots: &[String]) -> Vec<PathBuf> {
158 let base = prj.base.as_path();
159 let mut seen: HashSet<PathBuf> = HashSet::new();
160 let mut out: Vec<PathBuf> = Vec::new();
161 for root in roots {
162 let start = prj.root.join(root);
163 for entry in WalkDir::new(&start)
164 .into_iter()
165 .filter_entry(|e| !skip_dir(e, base))
166 .filter_map(|e| e.ok())
167 {
168 if entry.file_type().is_file() {
169 let p = entry.into_path();
170 if seen.insert(p.clone()) {
171 out.push(p);
172 }
173 }
174 }
175 }
176 out
177}
178
179/// Whether a walked directory should be pruned from the scan.
180fn skip_dir(e: &DirEntry, base: &Path) -> bool {
181 if !e.file_type().is_dir() {
182 return false;
183 }
184 // Never prune the scan root itself — a project may legitimately live in a
185 // hidden directory (e.g. a temp dir named `.tmpXXXX`), and `roots = ["."]`
186 // normalizes back to that dir's name.
187 if e.depth() == 0 {
188 return false;
189 }
190 let p = e.path();
191 if p == base {
192 return true; // the inventory itself — its ids live there by design
193 }
194 match p.file_name().and_then(|s| s.to_str()) {
195 Some(".git") | Some("target") | Some("node_modules") => true,
196 // Hidden directories (`.foo`), but never the scan root given as ".".
197 Some(name) => name.starts_with('.') && name.len() > 1,
198 None => false,
199 }
200}