Skip to main content

reliary_edit/
lib.rs

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