Skip to main content

hjkl_ex/
range.rs

1/// A parsed line range. 1-based, inclusive.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub struct LineRange {
4    start: usize,
5    end: usize,
6}
7
8impl LineRange {
9    pub fn new(start: usize, end: usize) -> Self {
10        Self { start, end }
11    }
12
13    pub fn start_one_based(&self) -> usize {
14        self.start
15    }
16
17    pub fn end_one_based(&self) -> usize {
18        self.end
19    }
20
21    pub fn single(line: usize) -> Self {
22        Self {
23            start: line,
24            end: line,
25        }
26    }
27}
28
29// ---- address parsing -------------------------------------------------------
30
31#[derive(Debug, Clone, Copy)]
32enum Address {
33    Number(usize), // 1-based, as the user typed
34    Current,
35    Last,
36    Mark(char),
37}
38
39/// Strip a leading address from `s`, return `(address, remainder)` or `None`.
40fn parse_address(s: &str) -> Option<(Address, &str)> {
41    let mut chars = s.char_indices();
42    let (_, first) = chars.next()?;
43    match first {
44        '.' => Some((Address::Current, &s[1..])),
45        '$' => Some((Address::Last, &s[1..])),
46        '\'' => {
47            let (_, mark) = chars.next()?;
48            Some((Address::Mark(mark), &s[2..]))
49        }
50        '0'..='9' => {
51            let mut end = 1;
52            for (i, c) in s.char_indices().skip(1) {
53                if c.is_ascii_digit() {
54                    end = i + c.len_utf8();
55                } else {
56                    break;
57                }
58            }
59            let n: usize = s[..end].parse().ok()?;
60            Some((Address::Number(n), &s[end..]))
61        }
62        _ => None,
63    }
64}
65
66/// Resolve a parsed address against the current editor state. Numbers are
67/// 1-based and clamped to the buffer; bad marks return an error.
68fn resolve_address<H: hjkl_engine::Host>(
69    addr: Address,
70    editor: &hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
71) -> Result<usize, String> {
72    let line_count = editor.buffer().row_count();
73    // 1-based last line (at least 1 so single-line buffers work)
74    let last = line_count.max(1);
75    match addr {
76        Address::Number(n) => Ok(n.clamp(1, last)),
77        Address::Current => Ok(editor.cursor().0 + 1), // cursor is 0-based
78        Address::Last => Ok(last),
79        Address::Mark(c) => editor
80            .mark(c)
81            .map(|(r, _)| (r + 1).min(last)) // 0-based → 1-based
82            .ok_or_else(|| format!("mark `{c}` not set")),
83    }
84}
85
86// ---- public API ------------------------------------------------------------
87
88/// Parse a leading range prefix from `cmd`. Supports:
89/// - `5`        → single line 5
90/// - `5,10`     → 5 through 10
91/// - `.,$`      → current line through last line
92/// - `'a,'b`    → mark a through mark b
93/// - `%`        → whole buffer (1 through line_count)
94///
95/// Returns `(parsed_range, remainder)`. `parsed_range` is `None` when the
96/// command starts with a non-range character (typical case for `:w`, `:e`).
97pub fn parse_range<'a, H: hjkl_engine::Host>(
98    cmd: &'a str,
99    editor: &hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
100) -> Result<(Option<LineRange>, &'a str), String> {
101    // `%` — whole buffer
102    if let Some(rest) = cmd.strip_prefix('%') {
103        let line_count = editor.buffer().row_count().max(1);
104        return Ok((Some(LineRange::new(1, line_count)), rest));
105    }
106
107    let Some((start_addr, after_start)) = parse_address(cmd) else {
108        return Ok((None, cmd));
109    };
110
111    let start = resolve_address(start_addr, editor)?;
112
113    if let Some(after_comma) = after_start.strip_prefix(',') {
114        // Expect a second address after the comma. If absent, error.
115        if after_comma.is_empty() {
116            return Err("missing end address after ','".into());
117        }
118        let Some((end_addr, rest)) = parse_address(after_comma) else {
119            // Something like `5,x` where `x` is not an address character
120            return Err(format!("invalid end address in range: `{after_comma}`"));
121        };
122        let end = resolve_address(end_addr, editor)?;
123        let (lo, hi) = if start <= end {
124            (start, end)
125        } else {
126            (end, start)
127        };
128        return Ok((Some(LineRange::new(lo, hi)), rest));
129    }
130
131    Ok((Some(LineRange::single(start)), after_start))
132}
133
134// ---- tests -----------------------------------------------------------------
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use hjkl_engine::{DefaultHost, Editor, Options};
140
141    fn make_editor_with_lines(lines: &[&str]) -> Editor<hjkl_buffer::Buffer, DefaultHost> {
142        use hjkl_buffer::Buffer;
143        let content = lines.join("\n");
144        let buf = Buffer::from_str(&content);
145        let host = DefaultHost::new();
146        Editor::new(buf, host, Options::default())
147    }
148
149    fn make_editor() -> Editor<hjkl_buffer::Buffer, DefaultHost> {
150        make_editor_with_lines(&["line1", "line2", "line3", "line4", "line5"])
151    }
152
153    // Helper: parse range on a 5-line editor, check start/end (1-based).
154    fn parse(cmd: &str) -> Result<(Option<(usize, usize)>, String), String> {
155        let e = make_editor();
156        parse_range(cmd, &e).map(|(r, rest)| (r.map(|lr| (lr.start, lr.end)), rest.to_owned()))
157    }
158
159    #[test]
160    fn bare_number() {
161        let (r, rest) = parse("5").unwrap();
162        assert_eq!(r, Some((5, 5)));
163        assert_eq!(rest, "");
164    }
165
166    #[test]
167    fn comma_separated() {
168        let (r, rest) = parse("5,10").unwrap();
169        // editor has 5 lines so 10 is clamped to 5
170        assert_eq!(r, Some((5, 5)));
171        assert_eq!(rest, "");
172    }
173
174    #[test]
175    fn comma_separated_within_range() {
176        let (r, rest) = parse("2,4").unwrap();
177        assert_eq!(r, Some((2, 4)));
178        assert_eq!(rest, "");
179    }
180
181    #[test]
182    fn percent_whole_buffer() {
183        let (r, rest) = parse("%").unwrap();
184        assert_eq!(r, Some((1, 5)));
185        assert_eq!(rest, "");
186    }
187
188    #[test]
189    fn dot_dollar() {
190        // cursor starts at row 0 (1-based: 1), last line is 5
191        let (r, rest) = parse(".,$").unwrap();
192        assert_eq!(r, Some((1, 5)));
193        assert_eq!(rest, "");
194    }
195
196    #[test]
197    fn mark_range() {
198        use hjkl_buffer::Buffer;
199        use hjkl_engine::{DefaultHost, Editor, Options};
200        let buf = Buffer::from_str("a\nb\nc\nd\ne");
201        let host = DefaultHost::new();
202        let mut editor = Editor::new(buf, host, Options::default());
203        // marks are 0-based internally; 1-based in range results
204        editor.set_mark('a', (0, 0)); // line 1
205        editor.set_mark('b', (2, 0)); // line 3
206        let (r, rest) = parse_range("'a,'b", &editor).unwrap();
207        assert_eq!(r, Some(LineRange::new(1, 3)));
208        assert_eq!(rest, "");
209    }
210
211    #[test]
212    fn range_followed_by_command() {
213        let (r, rest) = parse("5,10w").unwrap();
214        // 10 clamped to 5 (5-line buffer)
215        assert_eq!(r, Some((5, 5)));
216        assert_eq!(rest, "w");
217    }
218
219    #[test]
220    fn range_2_4_followed_by_command() {
221        let (r, rest) = parse("2,4w").unwrap();
222        assert_eq!(r, Some((2, 4)));
223        assert_eq!(rest, "w");
224    }
225
226    #[test]
227    fn no_range() {
228        let (r, rest) = parse("w").unwrap();
229        assert_eq!(r, None);
230        assert_eq!(rest, "w");
231    }
232
233    #[test]
234    fn invalid_end_address() {
235        let result = parse("5,x");
236        assert!(result.is_err(), "expected error for invalid end address");
237    }
238
239    #[test]
240    fn mark_not_set_returns_error() {
241        let result = parse("'z");
242        assert!(result.is_err());
243    }
244
245    #[test]
246    fn line_range_single_start_equals_end() {
247        let r = LineRange::single(5);
248        assert_eq!(r.start_one_based(), 5);
249        assert_eq!(r.end_one_based(), 5);
250    }
251}