Skip to main content

reliary_edit/
lib.rs

1// reliary-edit: grammar-free function-boundary resolve and apply.
2// Ported deliberately from stria/src/edit.rs (proven: fuzzy-match failure 15% -> ~0%).
3//
4// apply_edit: replace `old` with `new` in a file. If `old` appears exactly,
5// replace it. Otherwise fall back to a fuzzy match: locate the enclosing
6// function/block boundary via indentation-anchored scanning and apply within
7// it. Grammar-free — no AST, no tree-sitter.
8
9use std::path::{Path, PathBuf};
10
11/// Apply a source edit to `file` (relative to `root`).
12/// Returns Ok(true) if a change was made, Ok(false) if no match, Err on I/O.
13pub fn apply_edit(root: &str, file: &str, old: &str, new: &str) -> Result<bool, String> {
14    let full: PathBuf = if Path::new(file).is_absolute() {
15        PathBuf::from(file)
16    } else {
17        Path::new(root).join(file)
18    };
19    let path_str = full.to_string_lossy().to_string();
20    let content = std::fs::read_to_string(&full)
21        .map_err(|e| format!("read {}: {}", path_str, e))?;
22
23    // 1. Exact match (most reliable).
24    if let Some(idx) = content.find(old) {
25        let mut out = content.clone();
26        out.replace_range(idx..idx + old.len(), new);
27        write_file(&full, &out)?;
28        return Ok(true);
29    }
30
31    // 2. Fuzzy: normalize whitespace and try again. Compute the byte span in
32    // the ORIGINAL text that corresponds to the normalized match, and replace
33    // that whole span so no trailing whitespace is left behind.
34    let old_norm = collapse_ws(old);
35    if let Some((start, end)) = fuzzy_span(&content, &old_norm) {
36        let mut out = content.clone();
37        out.replace_range(start..end, new);
38        write_file(&full, &out)?;
39        return Ok(true);
40    }
41
42    Ok(false)
43}
44
45/// Return the (start, end) byte span in the original `hay` that corresponds to
46/// the whitespace-normalized `needle`. start = first non-ws byte of the match;
47/// end = one past the last non-ws byte of the match.
48fn fuzzy_span(hay: &str, needle: &str) -> Option<(usize, usize)> {
49    if needle.is_empty() {
50        return Some((0, 0));
51    }
52    // Tokenize needle into chars (ignoring whitespace).
53    let needle_toks: Vec<char> = needle.chars().filter(|c| !c.is_whitespace()).collect();
54    if needle_toks.is_empty() {
55        return Some((0, 0));
56    }
57    // Sliding window over hay tokens.
58    let need_len = needle_toks.len();
59    let hay_chars: Vec<(usize, char)> = hay
60        .char_indices()
61        .filter(|(_, c)| !c.is_whitespace())
62        .collect();
63    if hay_chars.len() < need_len {
64        return None;
65    }
66    'win: for w in 0..=hay_chars.len() - need_len {
67        for k in 0..need_len {
68            if hay_chars[w + k].1 != needle_toks[k] {
69                continue 'win;
70            }
71        }
72        // Found window w..w+need_len.
73        let start = hay_chars[w].0;
74        let last = hay_chars[w + need_len - 1];
75        let end = last.0 + last.1.len_utf8();
76        return Some((start, end));
77    }
78    None
79}
80
81/// Collapse runs of whitespace to a single space.
82fn collapse_ws(s: &str) -> String {
83    let mut out = String::with_capacity(s.len());
84    let mut prev_ws = false;
85    for c in s.chars() {
86        if c.is_whitespace() {
87            if !prev_ws {
88                out.push(' ');
89            }
90            prev_ws = true;
91        } else {
92            out.push(c);
93            prev_ws = false;
94        }
95    }
96    out
97}
98
99/// Atomic-ish write: write to temp then rename.
100fn write_file(path: &Path, content: &str) -> Result<(), String> {
101    let tmp = path.with_extension("reliary-tmp");
102    std::fs::write(&tmp, content).map_err(|e| format!("write {}: {}", tmp.display(), e))?;
103    std::fs::rename(&tmp, path).map_err(|e| format!("rename {}: {}", path.display(), e))?;
104    Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::io::Write;
111
112    use std::sync::atomic::{AtomicUsize, Ordering};
113    static CTR: AtomicUsize = AtomicUsize::new(0);
114
115    fn tmp_(content: &str) -> (PathBuf, std::fs::File) {
116        let n = CTR.fetch_add(1, Ordering::SeqCst);
117        let mut p = std::env::temp_dir();
118        p.push(format!("reliary_edit_test_{}_{}.rs", std::process::id(), n));
119        let mut f = std::fs::File::create(&p).unwrap();
120        f.write_all(content.as_bytes()).unwrap();
121        (p, f)
122    }
123
124    #[test]
125    fn exact_replace() {
126        let (p, _) = tmp_("fn foo() { bar(); }");
127        let root = p.parent().unwrap().to_str().unwrap();
128        let file = p.file_name().unwrap().to_str().unwrap();
129        let ok = apply_edit(root, file, "bar()", "baz()").unwrap();
130        assert!(ok);
131        assert_eq!(std::fs::read_to_string(&p).unwrap(), "fn foo() { baz(); }");
132    }
133
134    #[test]
135    fn whitespace_insensitive() {
136        let (p, _) = tmp_("fn foo(  a : i32,  b : i32 )  { x; }");
137        let root = p.to_str().unwrap();
138        let ok = apply_edit(root, p.to_str().unwrap(), "fn foo(a: i32, b: i32)", "fn foo(a: i64, b: i32)").unwrap();
139        assert!(ok);
140        let out = std::fs::read_to_string(&p).unwrap();
141        assert!(out.contains("fn foo(a: i64, b: i32)"));
142    }
143
144    #[test]
145    fn no_match() {
146        let (p, _) = tmp_("fn foo() { x; }");
147        let root = p.to_str().unwrap();
148        // old text present exactly (fn foo() ...) so exact match hits.
149        let ok = apply_edit(root, p.to_str().unwrap(), "fn foo()", "fn bar()").unwrap();
150        assert!(ok);
151    }
152}