Skip to main content

xei_core/
substitute.rs

1//! Ex-style substitute: `:s/pat/repl/flags` and `:%s/.../`
2
3#[derive(Clone, Debug, PartialEq, Eq)]
4pub struct SubstituteCmd {
5    pub pattern: String,
6    pub replacement: String,
7    /// whole file (`%s`)
8    pub global_file: bool,
9    /// all occurrences on each line (`g` flag)
10    pub global_line: bool,
11    pub confirm: bool,
12}
13
14/// Parse `s/pat/repl/flags`, `%s/pat/repl/g`, `s#pat#repl#g`
15pub fn parse_substitute(input: &str) -> Option<SubstituteCmd> {
16    let input = input.trim();
17    let (global_file, rest) = if let Some(r) = input.strip_prefix("%s") {
18        (true, r.trim_start())
19    } else if let Some(r) = input.strip_prefix('s') {
20        // not `set` / `save`
21        if r.starts_with(|c: char| c.is_alphanumeric()) {
22            return None;
23        }
24        (false, r.trim_start())
25    } else {
26        return None;
27    };
28
29    if rest.is_empty() {
30        return None;
31    }
32    let delim = rest.chars().next()?;
33    if delim.is_whitespace() {
34        return None;
35    }
36    let rest = &rest[delim.len_utf8()..];
37
38    let mut parts: Vec<String> = Vec::new();
39    let mut cur = String::new();
40    let mut escaped = false;
41    for ch in rest.chars() {
42        if escaped {
43            cur.push(ch);
44            escaped = false;
45            continue;
46        }
47        if ch == '\\' {
48            escaped = true;
49            continue;
50        }
51        if ch == delim {
52            parts.push(std::mem::take(&mut cur));
53        } else {
54            cur.push(ch);
55        }
56    }
57
58    // `s/foo/bar/g` → parts=[foo, bar], cur="g" (flags)
59    // `s/foo/bar/`  → parts=[foo, bar] or [foo, bar, ""], cur=""
60    let flags = if parts.len() >= 2 {
61        if parts.len() >= 3 {
62            // flags after third delim empty segment
63            let mut f = parts[2].clone();
64            f.push_str(&cur);
65            f
66        } else {
67            cur
68        }
69    } else {
70        // only one part so far — treat leftover as replacement
71        if !cur.is_empty() {
72            parts.push(cur);
73        }
74        String::new()
75    };
76
77    let pattern = parts.first()?.clone();
78    if pattern.is_empty() {
79        return None;
80    }
81    let replacement = parts.get(1).cloned().unwrap_or_default();
82    let global_line = flags.contains('g');
83    let confirm = flags.contains('c');
84
85    Some(SubstituteCmd {
86        pattern,
87        replacement,
88        global_file,
89        global_line,
90        confirm,
91    })
92}
93
94/// Apply substitute. Returns (new_lines, substitution count).
95pub fn apply_substitute(
96    lines: &[String],
97    cmd: &SubstituteCmd,
98    cursor_row: usize,
99) -> (Vec<String>, usize) {
100    let mut count = 0usize;
101    let mut out = lines.to_vec();
102    if out.is_empty() {
103        return (out, 0);
104    }
105
106    let rows: Vec<usize> = if cmd.global_file {
107        (0..out.len()).collect()
108    } else {
109        vec![cursor_row.min(out.len() - 1)]
110    };
111
112    for row in rows {
113        let (new_line, n) =
114            replace_on_line(&out[row], &cmd.pattern, &cmd.replacement, cmd.global_line);
115        if n > 0 {
116            out[row] = new_line;
117            count += n;
118        }
119    }
120    (out, count)
121}
122
123fn replace_on_line(line: &str, pat: &str, repl: &str, all: bool) -> (String, usize) {
124    if pat.is_empty() {
125        return (line.to_string(), 0);
126    }
127    if all {
128        let n = line.matches(pat).count();
129        (line.replace(pat, repl), n)
130    } else if let Some(idx) = line.find(pat) {
131        let mut s = String::with_capacity(line.len() + repl.len());
132        s.push_str(&line[..idx]);
133        s.push_str(repl);
134        s.push_str(&line[idx + pat.len()..]);
135        (s, 1)
136    } else {
137        (line.to_string(), 0)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn parse_basic() {
147        let c = parse_substitute("s/foo/bar/").unwrap();
148        assert_eq!(c.pattern, "foo");
149        assert_eq!(c.replacement, "bar");
150        assert!(!c.global_file);
151        assert!(!c.global_line);
152    }
153
154    #[test]
155    fn parse_global() {
156        let c = parse_substitute("%s/a/b/g").unwrap();
157        assert!(c.global_file);
158        assert!(c.global_line);
159        assert_eq!(c.pattern, "a");
160        assert_eq!(c.replacement, "b");
161    }
162
163    #[test]
164    fn apply_line() {
165        let lines = vec!["foo bar foo".into()];
166        let cmd = parse_substitute("s/foo/X/").unwrap();
167        let (out, n) = apply_substitute(&lines, &cmd, 0);
168        assert_eq!(n, 1);
169        assert_eq!(out[0], "X bar foo");
170    }
171
172    #[test]
173    fn apply_global() {
174        let lines = vec!["a a".into(), "a".into()];
175        let cmd = parse_substitute("%s/a/b/g").unwrap();
176        let (out, n) = apply_substitute(&lines, &cmd, 0);
177        assert_eq!(n, 3);
178        assert_eq!(out[0], "b b");
179        assert_eq!(out[1], "b");
180    }
181}