Skip to main content

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}