Skip to main content

hjkl_engine/
ex.rs

1//! Ex-command parser + executor.
2//!
3//! Parses the text after a leading `:` in the command-line prompt and
4//! returns an [`ExEffect`] describing what the caller should do. Only the
5//! editor-local effects (substitute, goto-line, clear-highlight) are
6//! applied in-place against `Editor`; quit / save / unknown are returned
7//! to the caller so the TUI loop can run them.
8
9use crate::editor::Editor;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ExEffect {
13    /// Nothing happened (empty input or already-applied effect).
14    None,
15    /// Save the current buffer.
16    Save,
17    /// Quit (`:q`, `:q!`, `:wq`, `:x`).
18    Quit { force: bool, save: bool },
19    /// Unknown command — caller should surface as an error toast.
20    Unknown(String),
21    /// Substitution finished — report replacement count.
22    Substituted { count: usize },
23    /// A no-op response for successful commands that don't need a side
24    /// effect but should not be reported as unknown (e.g. `:noh`).
25    Ok,
26    /// Surface an informational message.
27    Info(String),
28    /// Surface an error message (syntax error, bad pattern, …).
29    Error(String),
30}
31
32/// Parse and execute `input` (without the leading `:`).
33pub fn run(editor: &mut Editor<'_>, input: &str) -> ExEffect {
34    let cmd = input.trim();
35    if cmd.is_empty() {
36        return ExEffect::None;
37    }
38
39    // Strip a leading range (`5,10`, `.,$`, `'a,'b`, `%`). `range` is
40    // None when the user typed no addresses; the handler defaults to
41    // the command's natural scope (current line for `:s`, whole buffer
42    // for `:sort` / `:g`). Resolution errors surface as ExEffect::Error.
43    let (range, cmd) = match parse_range(cmd, editor) {
44        Ok(pair) => pair,
45        Err(e) => return ExEffect::Error(e),
46    };
47
48    // Bare line number — jump there. (Only when no range was parsed,
49    // since `parse_range` already consumes a leading number as an
50    // address; a bare `:5` falls through with `range = Some(5..=5)`
51    // and an empty `cmd`.)
52    if range.is_none() {
53        if let Ok(line) = cmd.parse::<usize>() {
54            editor.goto_line(line);
55            return ExEffect::Ok;
56        }
57    } else if cmd.is_empty() {
58        // `:5` jumps to line 5; `:5,10` lands on the start of the
59        // range (vim's behaviour for a bare-range command).
60        if let Some(r) = range {
61            editor.goto_line(r.start_one_based());
62            return ExEffect::Ok;
63        }
64    }
65
66    // `:q`, `:q!`, `:w`, `:wq`, `:x`.
67    match cmd {
68        "q" => {
69            return ExEffect::Quit {
70                force: false,
71                save: false,
72            };
73        }
74        "q!" => {
75            return ExEffect::Quit {
76                force: true,
77                save: false,
78            };
79        }
80        "w" => return ExEffect::Save,
81        "wq" | "x" => {
82            return ExEffect::Quit {
83                force: false,
84                save: true,
85            };
86        }
87        "noh" | "nohlsearch" => {
88            // Clearing the pattern removes the highlight.
89            editor.buffer_mut().set_search_pattern(None);
90            return ExEffect::Ok;
91        }
92        "reg" | "registers" => return ExEffect::Info(format_registers(editor)),
93        "marks" => return ExEffect::Info(format_marks(editor)),
94        "undo" | "u" => {
95            crate::vim::do_undo(editor);
96            return ExEffect::Ok;
97        }
98        "redo" | "red" => {
99            crate::vim::do_redo(editor);
100            return ExEffect::Ok;
101        }
102        "foldindent" | "foldi" => return apply_fold_indent(editor),
103        "foldsyntax" | "folds" => return apply_fold_syntax(editor),
104        _ => {}
105    }
106
107    // `:[range]sort[!][iun]` — defaults to the whole buffer when no
108    // range is given.
109    if let Some(rest) = cmd.strip_prefix("sort").or_else(|| cmd.strip_prefix("sor")) {
110        return apply_sort(editor, range, rest);
111    }
112
113    // `:set [option ...]` — toggle / assign vim options. Range is
114    // ignored (vim's `:set` doesn't accept one).
115    if let Some(rest) = cmd
116        .strip_prefix("set ")
117        .or_else(|| cmd.strip_prefix("se "))
118        .or(if cmd == "set" || cmd == "se" {
119            Some("")
120        } else {
121            None
122        })
123    {
124        return apply_set(editor, rest);
125    }
126
127    // `:[range]g/pat/cmd` and inverse `:v/pat/cmd`.
128    if let Some((negate, rest)) = parse_global_prefix(cmd) {
129        return apply_global(editor, range, rest, negate);
130    }
131
132    // `:[range]s/...` substitute. The legacy `:%s/...` form (no
133    // separate range) still works because `%` is parsed by
134    // `parse_range` above and consumed before we get here.
135    if let Some(rest) = cmd.strip_prefix('s') {
136        return match parse_substitute_body(rest) {
137            Ok(sub) => match apply_substitute(editor, range, sub) {
138                Ok(count) => ExEffect::Substituted { count },
139                Err(e) => ExEffect::Error(e),
140            },
141            Err(e) => ExEffect::Error(e),
142        };
143    }
144
145    // `:[range]d` — delete the range. Reuses :g/pat/d's row-drop loop.
146    if cmd == "d" {
147        return apply_delete_range(editor, range);
148    }
149
150    // `:r path` / `:read path` — insert file contents below the
151    // current line. Range is currently ignored; vim's `:Nr file`
152    // semantics (insert below row N) can land later if needed.
153    if let Some(path) = cmd.strip_prefix("read ").or_else(|| cmd.strip_prefix("r ")) {
154        return apply_read_file(editor, path.trim());
155    }
156
157    // `:[range]!cmd` — pipe rows through `cmd`, replace with stdout.
158    // Without a range, `:!cmd` runs the command and surfaces stdout
159    // as an Info toast (vim's `:!cmd` shows it in the message area).
160    if let Some(shell_cmd) = cmd.strip_prefix('!') {
161        return apply_shell_filter(editor, range, shell_cmd.trim());
162    }
163
164    ExEffect::Unknown(cmd.to_string())
165}
166
167/// `:foldsyntax` / `:folds` — apply the host-supplied syntax-tree
168/// block ranges as closed folds. the host calls
169/// [`Editor::set_syntax_fold_ranges`] on every tree-sitter re-parse;
170/// running this command consumes the latest snapshot. No-op when the
171/// host hasn't pushed any ranges yet.
172fn apply_fold_syntax(editor: &mut Editor<'_>) -> ExEffect {
173    let ranges = editor.syntax_fold_ranges.clone();
174    if ranges.is_empty() {
175        return ExEffect::Info("no syntax block ranges available".into());
176    }
177    let count = ranges.len();
178    for (start, end) in ranges {
179        editor.buffer_mut().add_fold(start, end, true);
180    }
181    ExEffect::Info(format!("created {count} fold(s)"))
182}
183
184/// `:foldindent` / `:foldi` — derive folds from leading-whitespace runs
185/// (vim's `foldmethod=indent`, fired manually because auto-fold-on-edit
186/// is expensive). Each row whose successor is more deeply indented
187/// becomes a fold opener; the fold extends to the row before indent
188/// drops back to or below the opener's level.
189fn apply_fold_indent(editor: &mut Editor<'_>) -> ExEffect {
190    let lines = editor.buffer().lines().to_vec();
191    let total = lines.len();
192    if total == 0 {
193        return ExEffect::Ok;
194    }
195    let indent =
196        |line: &str| -> usize { line.chars().take_while(|c| *c == ' ' || *c == '\t').count() };
197    let indents: Vec<usize> = lines.iter().map(|l| indent(l)).collect();
198    let blank: Vec<bool> = lines.iter().map(|l| l.trim().is_empty()).collect();
199    let mut new_folds: Vec<(usize, usize)> = Vec::new();
200    let mut i = 0;
201    while i + 1 < total {
202        if blank[i] {
203            i += 1;
204            continue;
205        }
206        let head_indent = indents[i];
207        let mut j = i + 1;
208        // Skip blanks adjacent to the head — they belong to the same
209        // block so a fold can span across them.
210        while j < total && blank[j] {
211            j += 1;
212        }
213        if j >= total || indents[j] <= head_indent {
214            i += 1;
215            continue;
216        }
217        // We have a fold opener — walk forward until indent drops back
218        // to <= head_indent on a non-blank row.
219        let mut end = j;
220        let mut k = j + 1;
221        while k < total {
222            if !blank[k] && indents[k] <= head_indent {
223                break;
224            }
225            end = k;
226            k += 1;
227        }
228        new_folds.push((i, end));
229        // Step by one (not past `end`) so nested indented runs inside
230        // the outer block also get their own fold.
231        i += 1;
232    }
233    if new_folds.is_empty() {
234        return ExEffect::Info("no indented blocks to fold".into());
235    }
236    let count = new_folds.len();
237    for (start, end) in new_folds {
238        editor.buffer_mut().add_fold(start, end, true);
239    }
240    ExEffect::Info(format!("created {count} fold(s)"))
241}
242
243/// `:[range]!cmd` — pipe the range through `cmd` (or run bare with no
244/// range). With a range, the rows are joined with `\n`, fed via
245/// stdin to `sh -c cmd`, and replaced with stdout. Without a range
246/// the command runs detached and stdout returns as an Info toast.
247fn apply_shell_filter(editor: &mut Editor<'_>, range: Option<Range>, cmd: &str) -> ExEffect {
248    if cmd.is_empty() {
249        return ExEffect::Error(":! needs a shell command".into());
250    }
251    use std::io::Write;
252    use std::process::{Command, Stdio};
253
254    if range.is_none() {
255        // Bare `:!cmd` — run, no buffer change, surface stdout via Info.
256        let output = Command::new("sh").arg("-c").arg(cmd).output();
257        return match output {
258            Ok(out) if out.status.success() => {
259                let stdout = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
260                if stdout.is_empty() {
261                    ExEffect::Info(format!("`{cmd}` exited 0"))
262                } else {
263                    ExEffect::Info(stdout)
264                }
265            }
266            Ok(out) => {
267                let stderr = String::from_utf8_lossy(&out.stderr);
268                let trimmed = stderr.trim();
269                let label = if trimmed.is_empty() {
270                    "no stderr".to_string()
271                } else {
272                    trimmed.to_string()
273                };
274                ExEffect::Error(format!(
275                    "command exited {} ({label})",
276                    out.status
277                        .code()
278                        .map(|c| c.to_string())
279                        .unwrap_or_else(|| "?".into())
280                ))
281            }
282            Err(e) => ExEffect::Error(format!("cannot run `{cmd}`: {e}")),
283        };
284    }
285
286    // Range supplied — pipe the rows through the command.
287    let scope = Range::or_default(range, Range::whole(editor));
288    let mut all_lines: Vec<String> = editor.buffer().lines().to_vec();
289    let total = all_lines.len();
290    if total == 0 {
291        return ExEffect::Ok;
292    }
293    let bot = scope.end.min(total - 1);
294    if scope.start > bot {
295        return ExEffect::Ok;
296    }
297    let payload = all_lines[scope.start..=bot].join("\n");
298    let mut child = match Command::new("sh")
299        .arg("-c")
300        .arg(cmd)
301        .stdin(Stdio::piped())
302        .stdout(Stdio::piped())
303        .stderr(Stdio::piped())
304        .spawn()
305    {
306        Ok(c) => c,
307        Err(e) => return ExEffect::Error(format!("cannot spawn `{cmd}`: {e}")),
308    };
309    if let Some(stdin) = child.stdin.as_mut()
310        && let Err(e) = stdin.write_all(payload.as_bytes())
311    {
312        return ExEffect::Error(format!("cannot write to `{cmd}`: {e}"));
313    }
314    let output = match child.wait_with_output() {
315        Ok(o) => o,
316        Err(e) => return ExEffect::Error(format!("`{cmd}` failed: {e}")),
317    };
318    if !output.status.success() {
319        let stderr = String::from_utf8_lossy(&output.stderr);
320        let trimmed = stderr.trim();
321        let label = if trimmed.is_empty() {
322            "no stderr".to_string()
323        } else {
324            trimmed.to_string()
325        };
326        return ExEffect::Error(format!(
327            "command exited {} ({label})",
328            output
329                .status
330                .code()
331                .map(|c| c.to_string())
332                .unwrap_or_else(|| "?".into())
333        ));
334    }
335    let stdout = match String::from_utf8(output.stdout) {
336        Ok(s) => s,
337        Err(_) => return ExEffect::Error("filter output was not UTF-8".into()),
338    };
339    let trimmed = stdout.strip_suffix('\n').unwrap_or(&stdout);
340    let new_rows: Vec<String> = trimmed.split('\n').map(String::from).collect();
341
342    editor.push_undo();
343    let after: Vec<String> = all_lines.split_off(bot + 1);
344    all_lines.truncate(scope.start);
345    all_lines.extend(new_rows);
346    all_lines.extend(after);
347    editor.restore(all_lines, (scope.start, 0));
348    editor.mark_dirty_after_ex();
349    ExEffect::Ok
350}
351
352/// `:r file` — read `path` from disk and insert below the current
353/// row. Cursor lands on the first row of the inserted content.
354/// Failures (missing file, permission denied) surface as
355/// `ExEffect::Error` toasts.
356fn apply_read_file(editor: &mut Editor<'_>, path: &str) -> ExEffect {
357    use hjkl_buffer::{Edit, Position};
358    if path.is_empty() {
359        return ExEffect::Error(":r needs a file path or `!cmd`".into());
360    }
361    // `:r !cmd` runs `cmd` through `sh -c` and inserts stdout. Same
362    // security posture as running anything from a shell — the user
363    // typed the command themselves.
364    let content = if let Some(cmd) = path.strip_prefix('!') {
365        let cmd = cmd.trim();
366        if cmd.is_empty() {
367            return ExEffect::Error(":r ! needs a shell command".into());
368        }
369        match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
370            Ok(out) if out.status.success() => match String::from_utf8(out.stdout) {
371                Ok(s) => s,
372                Err(_) => return ExEffect::Error("command output was not UTF-8".into()),
373            },
374            Ok(out) => {
375                let stderr = String::from_utf8_lossy(&out.stderr);
376                let trimmed = stderr.trim();
377                let label = if trimmed.is_empty() {
378                    "no stderr".to_string()
379                } else {
380                    trimmed.to_string()
381                };
382                return ExEffect::Error(format!(
383                    "command exited {} ({label})",
384                    out.status
385                        .code()
386                        .map(|c| c.to_string())
387                        .unwrap_or_else(|| "?".into())
388                ));
389            }
390            Err(e) => return ExEffect::Error(format!("cannot run `{cmd}`: {e}")),
391        }
392    } else {
393        match std::fs::read_to_string(path) {
394            Ok(s) => s,
395            Err(e) => return ExEffect::Error(format!("cannot read `{path}`: {e}")),
396        }
397    };
398    // Vim's `:r` inserts after the current row; trailing newline in
399    // the file is dropped to avoid a stray blank tail (vim does the
400    // same).
401    let trimmed = content.strip_suffix('\n').unwrap_or(&content);
402    editor.push_undo();
403    let row = editor.cursor().0;
404    let line_chars = editor
405        .buffer()
406        .line(row)
407        .map(|l| l.chars().count())
408        .unwrap_or(0);
409    let insert_text = format!("\n{trimmed}");
410    editor.mutate_edit(Edit::InsertStr {
411        at: Position::new(row, line_chars),
412        text: insert_text,
413    });
414    // Cursor lands on the first inserted row (row + 1) at col 0.
415    editor.jump_cursor(row + 1, 0);
416    editor.mark_dirty_after_ex();
417    ExEffect::Ok
418}
419
420/// 0-based, inclusive line range over the buffer.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422struct Range {
423    start: usize,
424    end: usize,
425}
426
427impl Range {
428    fn whole(editor: &Editor<'_>) -> Self {
429        let last = editor.buffer().lines().len().saturating_sub(1);
430        Self {
431            start: 0,
432            end: last,
433        }
434    }
435
436    fn single(row: usize) -> Self {
437        Self {
438            start: row,
439            end: row,
440        }
441    }
442
443    fn start_one_based(&self) -> usize {
444        self.start + 1
445    }
446
447    fn or_default(opt: Option<Self>, default: Self) -> Self {
448        opt.unwrap_or(default)
449    }
450}
451
452/// Single ex-mode address: `5`, `.`, `$`, `'a`. No `+/-` offset arith
453/// yet — keeps the parser tight.
454#[derive(Debug, Clone, Copy)]
455enum Address {
456    Number(usize), // 1-based, as the user typed it
457    Current,
458    Last,
459    Mark(char),
460}
461
462/// Strip a leading address from `s` and return it plus the remainder.
463/// Returns `None` when `s` doesn't start with one — the caller treats
464/// that as "no range provided".
465fn parse_address(s: &str) -> Option<(Address, &str)> {
466    let mut chars = s.char_indices();
467    let (_, first) = chars.next()?;
468    match first {
469        '.' => Some((Address::Current, &s[1..])),
470        '$' => Some((Address::Last, &s[1..])),
471        '\'' => {
472            let (_, mark) = chars.next()?;
473            Some((Address::Mark(mark), &s[2..]))
474        }
475        '0'..='9' => {
476            let mut end = 1;
477            for (i, c) in s.char_indices().skip(1) {
478                if c.is_ascii_digit() {
479                    end = i + c.len_utf8();
480                } else {
481                    break;
482                }
483            }
484            let n: usize = s[..end].parse().ok()?;
485            Some((Address::Number(n), &s[end..]))
486        }
487        _ => None,
488    }
489}
490
491/// Resolve a parsed address against the current editor state. Numeric
492/// addresses are clamped to the buffer; bad marks return an error.
493fn resolve_address(addr: Address, editor: &Editor<'_>) -> Result<usize, String> {
494    let last = editor.buffer().lines().len().saturating_sub(1);
495    match addr {
496        Address::Number(n) => Ok(n.saturating_sub(1).min(last)),
497        Address::Current => Ok(editor.cursor().0),
498        Address::Last => Ok(last),
499        Address::Mark(c) => editor
500            .vim
501            .marks
502            .get(&c)
503            .map(|(r, _)| (*r).min(last))
504            .ok_or_else(|| format!("mark `{c}` not set")),
505    }
506}
507
508/// Strip a leading range (`%`, `N`, `N,M`, `.,$`, `'a,'b`) from `cmd`.
509/// Returns the resolved 0-based inclusive range plus the remainder.
510fn parse_range<'a>(cmd: &'a str, editor: &Editor<'_>) -> Result<(Option<Range>, &'a str), String> {
511    if let Some(rest) = cmd.strip_prefix('%') {
512        return Ok((Some(Range::whole(editor)), rest));
513    }
514    let Some((start_addr, after_start)) = parse_address(cmd) else {
515        return Ok((None, cmd));
516    };
517    let start = resolve_address(start_addr, editor)?;
518    if let Some(after_comma) = after_start.strip_prefix(',') {
519        let (end_addr, rest) =
520            parse_address(after_comma).unwrap_or((Address::Number(start + 1), after_comma));
521        let end = resolve_address(end_addr, editor)?;
522        let (lo, hi) = if start <= end {
523            (start, end)
524        } else {
525            (end, start)
526        };
527        return Ok((Some(Range { start: lo, end: hi }), rest));
528    }
529    Ok((Some(Range::single(start)), after_start))
530}
531
532/// `:[range]d` — drop every row in the range.
533fn apply_delete_range(editor: &mut Editor<'_>, range: Option<Range>) -> ExEffect {
534    use hjkl_buffer::{Edit, MotionKind, Position};
535    let r = Range::or_default(range, Range::single(editor.cursor().0));
536    let total = editor.buffer().row_count();
537    if total == 0 {
538        return ExEffect::Ok;
539    }
540    let bot = r.end.min(total.saturating_sub(1));
541    if r.start > bot {
542        return ExEffect::Ok;
543    }
544    editor.push_undo();
545    // Delete bottom-up so row indices stay valid.
546    for row in (r.start..=bot).rev() {
547        if editor.buffer().row_count() == 1 {
548            let line_chars = editor
549                .buffer()
550                .line(0)
551                .map(|l| l.chars().count())
552                .unwrap_or(0);
553            if line_chars > 0 {
554                editor.mutate_edit(Edit::DeleteRange {
555                    start: Position::new(0, 0),
556                    end: Position::new(0, line_chars),
557                    kind: MotionKind::Char,
558                });
559            }
560            continue;
561        }
562        editor.mutate_edit(Edit::DeleteRange {
563            start: Position::new(row, 0),
564            end: Position::new(row, 0),
565            kind: MotionKind::Line,
566        });
567    }
568    editor.mark_dirty_after_ex();
569    ExEffect::Ok
570}
571
572/// Detect a `:g/pat/cmd`, `:g!/pat/cmd`, or `:v/pat/cmd` prefix.
573/// Returns `(negate, body_after_prefix)` where `body_after_prefix`
574/// still has the leading separator + pattern + cmd attached.
575fn parse_global_prefix(cmd: &str) -> Option<(bool, &str)> {
576    if let Some(rest) = cmd.strip_prefix("g!") {
577        return Some((true, rest));
578    }
579    if let Some(rest) = cmd.strip_prefix('v') {
580        return Some((true, rest));
581    }
582    if let Some(rest) = cmd.strip_prefix('g') {
583        return Some((false, rest));
584    }
585    None
586}
587
588/// Run `:[range]g/pat/d` (or its negated variants). Walks the rows in
589/// `range` (whole buffer when None), collects matches, then drops them
590/// in reverse so row indices stay valid through the cascade of deletes.
591fn apply_global(
592    editor: &mut Editor<'_>,
593    range: Option<Range>,
594    body: &str,
595    negate: bool,
596) -> ExEffect {
597    use hjkl_buffer::{Edit, MotionKind, Position};
598    let mut chars = body.chars();
599    let sep = match chars.next() {
600        Some(c) => c,
601        None => return ExEffect::Error("empty :g pattern".into()),
602    };
603    if sep.is_alphanumeric() || sep == '\\' {
604        return ExEffect::Error("global needs a separator, e.g. :g/foo/d".into());
605    }
606    let rest: String = chars.collect();
607    let parts = split_unescaped(&rest, sep);
608    if parts.len() < 2 {
609        return ExEffect::Error("global needs /pattern/cmd".into());
610    }
611    let pattern = unescape(&parts[0], sep);
612    let cmd = parts[1].trim();
613    if cmd != "d" {
614        return ExEffect::Error(format!(":g supports only `d` today, got `{cmd}`"));
615    }
616    let regex = match regex::Regex::new(&pattern) {
617        Ok(r) => r,
618        Err(e) => return ExEffect::Error(format!("bad pattern: {e}")),
619    };
620
621    editor.push_undo();
622    // Identify rows to drop (newest-first so multi-line drops don't
623    // shift indices under us). Default to the whole buffer when no
624    // range was supplied — matches vim's `:g/pat/d` (no range = `%`).
625    let scope = Range::or_default(range, Range::whole(editor));
626    let row_count = editor.buffer().row_count();
627    let bot = scope.end.min(row_count.saturating_sub(1));
628    let mut targets: Vec<usize> = Vec::new();
629    for row in scope.start..=bot {
630        let line = editor.buffer().line(row).unwrap_or("");
631        let matches = regex.is_match(line);
632        if matches != negate {
633            targets.push(row);
634        }
635    }
636    if targets.is_empty() {
637        editor.undo_stack.pop();
638        return ExEffect::Substituted { count: 0 };
639    }
640    let count = targets.len();
641    for row in targets.iter().rev() {
642        let row = *row;
643        // Last row in a 1-row buffer can't be removed (Buffer keeps
644        // the one-empty-row invariant); just clear it instead.
645        if editor.buffer().row_count() == 1 {
646            let line_chars = editor
647                .buffer()
648                .line(0)
649                .map(|l| l.chars().count())
650                .unwrap_or(0);
651            if line_chars > 0 {
652                editor.mutate_edit(Edit::DeleteRange {
653                    start: Position::new(0, 0),
654                    end: Position::new(0, line_chars),
655                    kind: MotionKind::Char,
656                });
657            }
658            continue;
659        }
660        editor.mutate_edit(Edit::DeleteRange {
661            start: Position::new(row, 0),
662            end: Position::new(row, 0),
663            kind: MotionKind::Line,
664        });
665    }
666    editor.mark_dirty_after_ex();
667    ExEffect::Substituted { count }
668}
669
670/// `:set [opt ...]` body. Splits on whitespace and applies each token.
671/// Bare `:set` reports the current values for the supported options.
672fn apply_set(editor: &mut Editor<'_>, body: &str) -> ExEffect {
673    let trimmed = body.trim();
674    if trimmed.is_empty() {
675        let s = editor.settings();
676        let wrap = match s.wrap {
677            hjkl_buffer::Wrap::None => "off",
678            hjkl_buffer::Wrap::Char => "char",
679            hjkl_buffer::Wrap::Word => "word",
680        };
681        return ExEffect::Info(format!(
682            "shiftwidth={}  tabstop={}  textwidth={}  ignorecase={}  wrap={}",
683            s.shiftwidth,
684            s.tabstop,
685            s.textwidth,
686            if s.ignore_case { "on" } else { "off" },
687            wrap,
688        ));
689    }
690    for token in trimmed.split_whitespace() {
691        if let Err(e) = apply_set_token(editor, token) {
692            return ExEffect::Error(e);
693        }
694    }
695    ExEffect::Ok
696}
697
698/// Apply a single `:set` token. Supports `name=value`, bare `name`
699/// (turns booleans on), and `noname` (turns booleans off).
700fn apply_set_token(editor: &mut Editor<'_>, token: &str) -> Result<(), String> {
701    if let Some((name, value)) = token.split_once('=') {
702        let parsed: usize = value
703            .parse()
704            .map_err(|_| format!("bad value `{value}` for :set {name}"))?;
705        match name {
706            "shiftwidth" | "sw" => {
707                if parsed == 0 {
708                    return Err("shiftwidth must be > 0".into());
709                }
710                editor.settings_mut().shiftwidth = parsed;
711            }
712            "tabstop" | "ts" => {
713                if parsed == 0 {
714                    return Err("tabstop must be > 0".into());
715                }
716                editor.settings_mut().tabstop = parsed;
717            }
718            "textwidth" | "tw" => {
719                if parsed == 0 {
720                    return Err("textwidth must be > 0".into());
721                }
722                editor.settings_mut().textwidth = parsed;
723            }
724            other => return Err(format!("unknown :set option `{other}`")),
725        }
726        return Ok(());
727    }
728    let (name, value) = if let Some(rest) = token.strip_prefix("no") {
729        (rest, false)
730    } else {
731        (token, true)
732    };
733    match name {
734        "ignorecase" | "ic" => editor.settings_mut().ignore_case = value,
735        "wrap" => {
736            editor.settings_mut().wrap = if value {
737                // Preserve `Wrap::Word` if `linebreak` already flipped
738                // word-mode on; otherwise default `set wrap` to char.
739                match editor.settings().wrap {
740                    hjkl_buffer::Wrap::Word => hjkl_buffer::Wrap::Word,
741                    _ => hjkl_buffer::Wrap::Char,
742                }
743            } else {
744                hjkl_buffer::Wrap::None
745            };
746        }
747        "linebreak" | "lbr" => {
748            editor.settings_mut().wrap = if value {
749                hjkl_buffer::Wrap::Word
750            } else {
751                // `nolinebreak` drops back to char wrap when wrap is on,
752                // otherwise stays off.
753                match editor.settings().wrap {
754                    hjkl_buffer::Wrap::None => hjkl_buffer::Wrap::None,
755                    _ => hjkl_buffer::Wrap::Char,
756                }
757            };
758        }
759        // Booleans we don't (yet) honour: accept silently so :set lines
760        // copied from a vimrc don't error out. `foldenable` falls here.
761        "foldenable" | "fen" => {}
762        other => return Err(format!("unknown :set option `{other}`")),
763    }
764    Ok(())
765}
766
767/// `:[range]sort[!][iun]` body — `flags` is whatever followed the
768/// command name (e.g. `!u`, ` un`, `i`). Sorts only the rows in `range`
769/// (or the whole buffer when None).
770fn apply_sort(editor: &mut Editor<'_>, range: Option<Range>, flags: &str) -> ExEffect {
771    let trimmed = flags.trim();
772    let mut reverse = false;
773    let mut unique = false;
774    let mut numeric = false;
775    let mut ignore_case = false;
776    for c in trimmed.chars() {
777        match c {
778            '!' => reverse = true,
779            'u' => unique = true,
780            'n' => numeric = true,
781            'i' => ignore_case = true,
782            ' ' | '\t' => {}
783            other => return ExEffect::Error(format!("bad :sort flag `{other}`")),
784        }
785    }
786
787    let mut all_lines: Vec<String> = editor.buffer().lines().to_vec();
788    let total = all_lines.len();
789    if total == 0 {
790        return ExEffect::Ok;
791    }
792    let scope = Range::or_default(range, Range::whole(editor));
793    let bot = scope.end.min(total - 1);
794    if scope.start > bot {
795        return ExEffect::Ok;
796    }
797    // Sort only the slice in range; keep the rest of the buffer intact.
798    let mut slice: Vec<String> = all_lines[scope.start..=bot].to_vec();
799    if numeric {
800        // Vim's `:sort n`: extract the first decimal integer (with
801        // optional leading `-`) on each line; lines with no number
802        // sort first, in original order.
803        slice.sort_by_key(|l| extract_leading_number(l));
804    } else if ignore_case {
805        slice.sort_by_key(|s| s.to_lowercase());
806    } else {
807        slice.sort();
808    }
809    if reverse {
810        slice.reverse();
811    }
812    if unique {
813        let cmp_key = |s: &str| -> String {
814            if ignore_case {
815                s.to_lowercase()
816            } else {
817                s.to_string()
818            }
819        };
820        let mut seen = std::collections::HashSet::new();
821        slice.retain(|line| seen.insert(cmp_key(line)));
822    }
823    // Splice the sorted slice back. `unique` may have shortened it.
824    let after: Vec<String> = all_lines.split_off(bot + 1);
825    all_lines.truncate(scope.start);
826    all_lines.extend(slice);
827    all_lines.extend(after);
828
829    editor.push_undo();
830    editor.restore(all_lines, (scope.start, 0));
831    editor.mark_dirty_after_ex();
832    ExEffect::Ok
833}
834
835/// Parse the first signed decimal integer from `line` for `:sort n`.
836/// Lines with no leading number sort as `i64::MIN` so they cluster at
837/// the top, matching vim's behaviour.
838fn extract_leading_number(line: &str) -> i64 {
839    let bytes = line.as_bytes();
840    let mut i = 0;
841    while i < bytes.len() && !bytes[i].is_ascii_digit() && bytes[i] != b'-' {
842        i += 1;
843    }
844    if i >= bytes.len() {
845        return i64::MIN;
846    }
847    let mut j = i;
848    if bytes[j] == b'-' {
849        j += 1;
850    }
851    let start = j;
852    while j < bytes.len() && bytes[j].is_ascii_digit() {
853        j += 1;
854    }
855    if j == start {
856        return i64::MIN;
857    }
858    line[i..j].parse().unwrap_or(i64::MIN)
859}
860
861/// `:reg` / `:registers` — tabular dump of every non-empty register slot.
862fn format_registers(editor: &Editor<'_>) -> String {
863    let r = editor.registers();
864    let mut lines = vec!["--- Registers ---".to_string()];
865    let mut push = |sel: &str, text: &str, linewise: bool| {
866        if text.is_empty() {
867            return;
868        }
869        let marker = if linewise { "L" } else { " " };
870        lines.push(format!("{sel:<3} {marker} {}", display_register(text)));
871    };
872    push("\"\"", &r.unnamed.text, r.unnamed.linewise);
873    push("\"0", &r.yank_zero.text, r.yank_zero.linewise);
874    for (i, slot) in r.delete_ring.iter().enumerate() {
875        let sel = format!("\"{}", i + 1);
876        push(&sel, &slot.text, slot.linewise);
877    }
878    for (i, slot) in r.named.iter().enumerate() {
879        let sel = format!("\"{}", (b'a' + i as u8) as char);
880        push(&sel, &slot.text, slot.linewise);
881    }
882    if lines.len() == 1 {
883        lines.push("(no registers set)".to_string());
884    }
885    lines.join("\n")
886}
887
888/// Escape control chars + truncate so a multi-line register fits a single row
889/// of the toast table.
890fn display_register(text: &str) -> String {
891    let escaped: String = text
892        .chars()
893        .map(|c| match c {
894            '\n' => "\\n".to_string(),
895            '\t' => "\\t".to_string(),
896            '\r' => "\\r".to_string(),
897            c => c.to_string(),
898        })
899        .collect();
900    const MAX: usize = 60;
901    if escaped.chars().count() > MAX {
902        let head: String = escaped.chars().take(MAX - 3).collect();
903        format!("{head}...")
904    } else {
905        escaped
906    }
907}
908
909/// `:marks` — list every set mark with `(line, col)`. Lines are 1-based to
910/// match vim; cols are 0-based.
911fn format_marks(editor: &Editor<'_>) -> String {
912    let mut lines = vec!["--- Marks ---".to_string(), "mark  line  col".to_string()];
913    let mut entries: Vec<(char, usize, usize)> = editor
914        .vim
915        .marks
916        .iter()
917        .map(|(c, (r, col))| (*c, *r, *col))
918        .collect();
919    // Uppercase / file marks live separately on Editor.
920    entries.extend(editor.file_marks.iter().map(|(c, (r, col))| (*c, *r, *col)));
921    entries.sort_by_key(|(c, _, _)| *c);
922    for (c, r, col) in entries {
923        lines.push(format!(" {c}    {:>4}  {col:>3}", r + 1));
924    }
925    if let Some((r, col)) = editor.vim.jump_back.last() {
926        lines.push(format!(" '    {:>4}  {col:>3}", r + 1));
927    }
928    if let Some((r, col)) = editor.vim.last_edit_pos {
929        lines.push(format!(" .    {:>4}  {col:>3}", r + 1));
930    }
931    if lines.len() == 2 {
932        lines.push("(no marks set)".to_string());
933    }
934    lines.join("\n")
935}
936
937#[derive(Debug, Clone, PartialEq, Eq)]
938struct Substitute {
939    pattern: String,
940    replacement: String,
941    global: bool,
942    case_insensitive: bool,
943}
944
945/// Parse the `/pat/repl/flags` tail of a substitute command. The leading
946/// `s` or `%s` has already been stripped. The separator is the first
947/// character after the optional scope (typically `/`), matching vim.
948fn parse_substitute_body(body: &str) -> Result<Substitute, String> {
949    let mut chars = body.chars();
950    let sep = chars.next().ok_or_else(|| "empty substitute".to_string())?;
951    if sep.is_alphanumeric() || sep == '\\' {
952        return Err("substitute needs a separator, e.g. :s/foo/bar/".into());
953    }
954    let rest: String = chars.collect();
955    let parts = split_unescaped(&rest, sep);
956    if parts.len() < 2 {
957        return Err("substitute needs /pattern/replacement/".into());
958    }
959    let pattern = unescape(&parts[0], sep);
960    let replacement = unescape(&parts[1], sep);
961    let flags = parts.get(2).cloned().unwrap_or_default();
962    let mut global = false;
963    let mut case_insensitive = false;
964    for f in flags.chars() {
965        match f {
966            'g' => global = true,
967            'i' => case_insensitive = true,
968            'c' => {
969                return Err("interactive substitution (c flag) is not supported".into());
970            }
971            other => return Err(format!("unknown substitute flag: {other}")),
972        }
973    }
974    Ok(Substitute {
975        pattern,
976        replacement,
977        global,
978        case_insensitive,
979    })
980}
981
982/// Split `s` by `sep`, treating `\<sep>` as a literal occurrence.
983fn split_unescaped(s: &str, sep: char) -> Vec<String> {
984    let mut out = Vec::new();
985    let mut cur = String::new();
986    let mut chars = s.chars().peekable();
987    while let Some(c) = chars.next() {
988        if c == '\\' {
989            if let Some(&next) = chars.peek() {
990                // Preserve the escape so regex metachars survive; only
991                // collapse an escaped separator into a literal separator.
992                if next == sep {
993                    cur.push(sep);
994                    chars.next();
995                } else {
996                    cur.push('\\');
997                    cur.push(next);
998                    chars.next();
999                }
1000            } else {
1001                cur.push('\\');
1002            }
1003        } else if c == sep {
1004            out.push(std::mem::take(&mut cur));
1005        } else {
1006            cur.push(c);
1007        }
1008    }
1009    out.push(cur);
1010    out
1011}
1012
1013/// Remove our `\<sep>` → `<sep>` escape. Other `\x` sequences pass
1014/// through so regex escape syntax (`\b`, `\d`, …) still works.
1015fn unescape(s: &str, _sep: char) -> String {
1016    s.to_string()
1017}
1018
1019fn apply_substitute(
1020    editor: &mut Editor<'_>,
1021    range: Option<Range>,
1022    sub: Substitute,
1023) -> Result<usize, String> {
1024    // Explicit `i` flag wins, otherwise honour the global `:set
1025    // ignorecase` switch.
1026    let case_insensitive = sub.case_insensitive || editor.settings().ignore_case;
1027    let pattern = if case_insensitive {
1028        format!("(?i){}", sub.pattern)
1029    } else {
1030        sub.pattern.clone()
1031    };
1032    let regex = regex::Regex::new(&pattern).map_err(|e| format!("bad pattern: {e}"))?;
1033
1034    editor.push_undo();
1035
1036    // No range = current line only — matches vim's `:s` default.
1037    let scope = Range::or_default(range, Range::single(editor.cursor().0));
1038    let (range_start, range_end) = (scope.start, scope.end);
1039
1040    let mut new_lines: Vec<String> = editor.buffer().lines().to_vec();
1041    let mut count = 0usize;
1042    let clamp = range_end.min(new_lines.len().saturating_sub(1));
1043    for line in new_lines[range_start..=clamp].iter_mut() {
1044        let (replaced, n) = regex_replace(&regex, line, &sub.replacement, sub.global);
1045        *line = replaced;
1046        count += n;
1047    }
1048
1049    if count == 0 {
1050        // Undo the undo push so the user doesn't see an empty undo step.
1051        editor.undo_stack.pop();
1052        return Ok(0);
1053    }
1054
1055    // Apply the new content. Yank survives across loads since it's
1056    // owned by Editor now (was previously held by the textarea).
1057    editor.buffer_mut().replace_all(&new_lines.join("\n"));
1058    editor
1059        .buffer_mut()
1060        .set_cursor(hjkl_buffer::Position::new(range_start, 0));
1061    editor.mark_dirty_after_ex();
1062    Ok(count)
1063}
1064
1065/// Count-returning variant of `Regex::replace` / `replace_all`. The
1066/// replacement is first translated from vim's notation (`&`) to the
1067/// regex crate's (`$0`) so `$n` interpolation still runs.
1068fn regex_replace(
1069    regex: &regex::Regex,
1070    text: &str,
1071    replacement: &str,
1072    global: bool,
1073) -> (String, usize) {
1074    let matches = regex.find_iter(text).count();
1075    if matches == 0 {
1076        return (text.to_string(), 0);
1077    }
1078    let rep = expand_vim_replacement(replacement);
1079    let replaced = if global {
1080        regex.replace_all(text, rep.as_str()).into_owned()
1081    } else {
1082        regex.replace(text, rep.as_str()).into_owned()
1083    };
1084    let count = if global { matches } else { 1 };
1085    (replaced, count)
1086}
1087
1088/// Translate vim-ish replacement placeholders to regex ones. For now only
1089/// `&` → the whole match; vim also supports `\0-\9` which the `regex`
1090/// crate already honours, so we leave those alone.
1091fn expand_vim_replacement(input: &str) -> String {
1092    let mut out = String::with_capacity(input.len());
1093    let mut chars = input.chars().peekable();
1094    while let Some(c) = chars.next() {
1095        if c == '\\' {
1096            if let Some(&next) = chars.peek() {
1097                out.push('\\');
1098                out.push(next);
1099                chars.next();
1100            } else {
1101                out.push('\\');
1102            }
1103        } else if c == '&' {
1104            // `&` in vim replacement == whole match, same as `$0` for `regex`.
1105            out.push_str("$0");
1106        } else {
1107            out.push(c);
1108        }
1109    }
1110    out
1111}
1112
1113impl<'a> Editor<'a> {
1114    /// Called by ex-command handlers after they rewrite the buffer.
1115    /// Ensures dirty tracking and undo bookkeeping stay consistent.
1116    fn mark_dirty_after_ex(&mut self) {
1117        self.mark_content_dirty();
1118    }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124    use crate::KeybindingMode;
1125    use crate::editor::Editor;
1126    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1127
1128    fn new(content: &str) -> Editor<'static> {
1129        let mut e = Editor::new(KeybindingMode::Vim);
1130        e.set_content(content);
1131        e
1132    }
1133
1134    fn type_keys(e: &mut Editor<'_>, keys: &str) {
1135        for c in keys.chars() {
1136            let ev = match c {
1137                '\n' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
1138                '\x1b' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1139                ch => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
1140            };
1141            e.handle_key(ev);
1142        }
1143    }
1144
1145    #[test]
1146    fn substitute_current_line() {
1147        let mut e = new("foo foo\nfoo foo");
1148        let effect = run(&mut e, "s/foo/bar/");
1149        assert_eq!(effect, ExEffect::Substituted { count: 1 });
1150        assert_eq!(e.buffer().lines()[0], "bar foo");
1151        assert_eq!(e.buffer().lines()[1], "foo foo");
1152    }
1153
1154    #[test]
1155    fn substitute_current_line_global() {
1156        let mut e = new("foo foo\nfoo");
1157        run(&mut e, "s/foo/bar/g");
1158        assert_eq!(e.buffer().lines()[0], "bar bar");
1159        assert_eq!(e.buffer().lines()[1], "foo");
1160    }
1161
1162    #[test]
1163    fn substitute_whole_buffer_global() {
1164        let mut e = new("foo\nfoo foo\nbar");
1165        let effect = run(&mut e, "%s/foo/xyz/g");
1166        assert_eq!(effect, ExEffect::Substituted { count: 3 });
1167        assert_eq!(e.buffer().lines()[0], "xyz");
1168        assert_eq!(e.buffer().lines()[1], "xyz xyz");
1169        assert_eq!(e.buffer().lines()[2], "bar");
1170    }
1171
1172    #[test]
1173    fn substitute_zero_matches_reports_zero() {
1174        let mut e = new("hello");
1175        let effect = run(&mut e, "s/xyz/abc/");
1176        assert_eq!(effect, ExEffect::Substituted { count: 0 });
1177        assert_eq!(e.buffer().lines()[0], "hello");
1178    }
1179
1180    #[test]
1181    fn substitute_respects_case_insensitive_flag() {
1182        let mut e = new("Foo");
1183        let effect = run(&mut e, "s/foo/bar/i");
1184        assert_eq!(effect, ExEffect::Substituted { count: 1 });
1185        assert_eq!(e.buffer().lines()[0], "bar");
1186    }
1187
1188    #[test]
1189    fn substitute_accepts_alternate_separator() {
1190        let mut e = new("/usr/local/bin");
1191        run(&mut e, "s#/usr#/opt#");
1192        assert_eq!(e.buffer().lines()[0], "/opt/local/bin");
1193    }
1194
1195    #[test]
1196    fn substitute_ampersand_in_replacement() {
1197        let mut e = new("foo");
1198        run(&mut e, "s/foo/[&]/");
1199        assert_eq!(e.buffer().lines()[0], "[foo]");
1200    }
1201
1202    #[test]
1203    fn goto_line() {
1204        let mut e = new("a\nb\nc\nd");
1205        run(&mut e, "3");
1206        assert_eq!(e.cursor().0, 2);
1207    }
1208
1209    #[test]
1210    fn quit_and_force_quit() {
1211        let mut e = new("");
1212        assert_eq!(
1213            run(&mut e, "q"),
1214            ExEffect::Quit {
1215                force: false,
1216                save: false
1217            }
1218        );
1219        assert_eq!(
1220            run(&mut e, "q!"),
1221            ExEffect::Quit {
1222                force: true,
1223                save: false
1224            }
1225        );
1226        assert_eq!(
1227            run(&mut e, "wq"),
1228            ExEffect::Quit {
1229                force: false,
1230                save: true
1231            }
1232        );
1233    }
1234
1235    #[test]
1236    fn write_returns_save() {
1237        let mut e = new("");
1238        assert_eq!(run(&mut e, "w"), ExEffect::Save);
1239    }
1240
1241    #[test]
1242    fn noh_is_ok() {
1243        let mut e = new("");
1244        assert_eq!(run(&mut e, "noh"), ExEffect::Ok);
1245    }
1246
1247    #[test]
1248    fn registers_lists_unnamed_and_named() {
1249        let mut e = new("hello world");
1250        // `yw` populates `"` and `"0`; `"ayw` also fills `"a`.
1251        type_keys(&mut e, "yw");
1252        type_keys(&mut e, "\"ayw");
1253        let info = match run(&mut e, "reg") {
1254            ExEffect::Info(s) => s,
1255            other => panic!("expected Info, got {other:?}"),
1256        };
1257        assert!(info.starts_with("--- Registers ---"));
1258        assert!(info.contains("\"\""));
1259        assert!(info.contains("\"0"));
1260        assert!(info.contains("\"a"));
1261        // Alias resolves to same command.
1262        assert_eq!(run(&mut e, "registers"), ExEffect::Info(info));
1263    }
1264
1265    #[test]
1266    fn registers_empty_state() {
1267        let mut e = new("hi");
1268        let info = match run(&mut e, "reg") {
1269            ExEffect::Info(s) => s,
1270            other => panic!("expected Info, got {other:?}"),
1271        };
1272        assert!(info.contains("(no registers set)"));
1273    }
1274
1275    #[test]
1276    fn marks_lists_user_and_special() {
1277        let mut e = new("alpha\nbeta\ngamma");
1278        type_keys(&mut e, "ma");
1279        type_keys(&mut e, "jjmb");
1280        // `iX<Esc>` produces a last_edit_pos.
1281        type_keys(&mut e, "iX");
1282        let info = match run(&mut e, "marks") {
1283            ExEffect::Info(s) => s,
1284            other => panic!("expected Info, got {other:?}"),
1285        };
1286        assert!(info.starts_with("--- Marks ---"));
1287        assert!(info.contains(" a "));
1288        assert!(info.contains(" b "));
1289        assert!(info.contains(" . "));
1290    }
1291
1292    #[test]
1293    fn undo_alias_reverses_last_change() {
1294        let mut e = new("hello");
1295        type_keys(&mut e, "Aworld\x1b");
1296        assert_eq!(e.buffer().lines()[0], "helloworld");
1297        assert_eq!(run(&mut e, "undo"), ExEffect::Ok);
1298        assert_eq!(e.buffer().lines()[0], "hello");
1299        // Short alias.
1300        type_keys(&mut e, "Awow\x1b");
1301        assert_eq!(e.buffer().lines()[0], "hellowow");
1302        assert_eq!(run(&mut e, "u"), ExEffect::Ok);
1303        assert_eq!(e.buffer().lines()[0], "hello");
1304    }
1305
1306    #[test]
1307    fn redo_alias_reapplies_undone_change() {
1308        let mut e = new("hi");
1309        type_keys(&mut e, "Athere\x1b");
1310        assert_eq!(e.buffer().lines()[0], "hithere");
1311        run(&mut e, "undo");
1312        assert_eq!(e.buffer().lines()[0], "hi");
1313        assert_eq!(run(&mut e, "redo"), ExEffect::Ok);
1314        assert_eq!(e.buffer().lines()[0], "hithere");
1315        // Short alias.
1316        run(&mut e, "u");
1317        assert_eq!(run(&mut e, "red"), ExEffect::Ok);
1318        assert_eq!(e.buffer().lines()[0], "hithere");
1319    }
1320
1321    #[test]
1322    fn marks_empty_state() {
1323        let mut e = new("hi");
1324        let info = match run(&mut e, "marks") {
1325            ExEffect::Info(s) => s,
1326            other => panic!("expected Info, got {other:?}"),
1327        };
1328        assert!(info.contains("(no marks set)"));
1329    }
1330
1331    #[test]
1332    fn sort_alphabetical() {
1333        let mut e = new("banana\napple\ncherry");
1334        assert_eq!(run(&mut e, "sort"), ExEffect::Ok);
1335        assert_eq!(
1336            e.buffer().lines(),
1337            vec!["apple".to_string(), "banana".into(), "cherry".into()]
1338        );
1339    }
1340
1341    #[test]
1342    fn sort_reverse_with_bang() {
1343        let mut e = new("apple\nbanana\ncherry");
1344        run(&mut e, "sort!");
1345        assert_eq!(
1346            e.buffer().lines(),
1347            vec!["cherry".to_string(), "banana".into(), "apple".into()]
1348        );
1349    }
1350
1351    #[test]
1352    fn sort_unique() {
1353        let mut e = new("foo\nbar\nfoo\nbaz\nbar");
1354        run(&mut e, "sort u");
1355        assert_eq!(
1356            e.buffer().lines(),
1357            vec!["bar".to_string(), "baz".into(), "foo".into()]
1358        );
1359    }
1360
1361    #[test]
1362    fn sort_numeric() {
1363        let mut e = new("10\n2\n100\n7");
1364        run(&mut e, "sort n");
1365        assert_eq!(
1366            e.buffer().lines(),
1367            vec!["2".to_string(), "7".into(), "10".into(), "100".into()]
1368        );
1369    }
1370
1371    #[test]
1372    fn sort_ignore_case() {
1373        let mut e = new("Banana\napple\nCherry");
1374        run(&mut e, "sort i");
1375        assert_eq!(
1376            e.buffer().lines(),
1377            vec!["apple".to_string(), "Banana".into(), "Cherry".into()]
1378        );
1379    }
1380
1381    #[test]
1382    fn sort_undo_restores_original_order() {
1383        let mut e = new("c\nb\na");
1384        run(&mut e, "sort");
1385        assert_eq!(e.buffer().lines()[0], "a");
1386        crate::vim::do_undo(&mut e);
1387        assert_eq!(
1388            e.buffer().lines(),
1389            vec!["c".to_string(), "b".into(), "a".into()]
1390        );
1391    }
1392
1393    #[test]
1394    fn sort_rejects_unknown_flag() {
1395        let mut e = new("a\nb");
1396        match run(&mut e, "sortz") {
1397            ExEffect::Error(msg) => assert!(msg.contains("z")),
1398            other => panic!("expected Error, got {other:?}"),
1399        }
1400    }
1401
1402    #[test]
1403    fn range_sort_partial() {
1404        // `:2,4sort` sorts rows 1..=3 (1-based 2..=4) only.
1405        let mut e = new("z\nc\nb\na\nx");
1406        run(&mut e, "2,4sort");
1407        assert_eq!(
1408            e.buffer().lines(),
1409            vec![
1410                "z".to_string(),
1411                "a".into(),
1412                "b".into(),
1413                "c".into(),
1414                "x".into(),
1415            ]
1416        );
1417    }
1418
1419    #[test]
1420    fn range_substitute_partial() {
1421        let mut e = new("foo\nfoo\nfoo\nfoo");
1422        // `:2,3s/foo/bar/` only replaces lines 2 and 3.
1423        let effect = run(&mut e, "2,3s/foo/bar/");
1424        assert_eq!(effect, ExEffect::Substituted { count: 2 });
1425        assert_eq!(
1426            e.buffer().lines(),
1427            vec!["foo".to_string(), "bar".into(), "bar".into(), "foo".into(),]
1428        );
1429    }
1430
1431    #[test]
1432    fn range_delete_drops_lines() {
1433        let mut e = new("a\nb\nc\nd\ne");
1434        run(&mut e, "2,4d");
1435        assert_eq!(e.buffer().lines(), vec!["a".to_string(), "e".into()]);
1436    }
1437
1438    #[test]
1439    fn percent_substitute_still_works() {
1440        let mut e = new("foo\nfoo");
1441        let effect = run(&mut e, "%s/foo/bar/");
1442        assert_eq!(effect, ExEffect::Substituted { count: 2 });
1443        assert_eq!(e.buffer().lines(), vec!["bar".to_string(), "bar".into()]);
1444    }
1445
1446    #[test]
1447    fn dot_dollar_addresses_resolve() {
1448        let mut e = new("a\nb\nc\nd");
1449        e.jump_cursor(1, 0);
1450        // `.,$d` deletes from the current row to the bottom.
1451        run(&mut e, ".,$d");
1452        assert_eq!(e.buffer().lines(), vec!["a".to_string()]);
1453    }
1454
1455    #[test]
1456    fn mark_address_resolves() {
1457        let mut e = new("a\nb\nc\nd\ne");
1458        // Set marks `a` on row 1, `b` on row 3.
1459        e.jump_cursor(1, 0);
1460        type_keys(&mut e, "ma");
1461        e.jump_cursor(3, 0);
1462        type_keys(&mut e, "mb");
1463        run(&mut e, "'a,'bd");
1464        assert_eq!(e.buffer().lines(), vec!["a".to_string(), "e".into()]);
1465    }
1466
1467    #[test]
1468    fn range_global_partial() {
1469        let mut e = new("foo\nfoo\nbar\nfoo\nfoo");
1470        // Only delete `foo` lines in rows 2..=4.
1471        run(&mut e, "2,4g/foo/d");
1472        assert_eq!(
1473            e.buffer().lines(),
1474            vec!["foo".to_string(), "bar".into(), "foo".into()]
1475        );
1476    }
1477
1478    #[test]
1479    fn bare_line_number_jumps() {
1480        let mut e = new("a\nb\nc\nd");
1481        run(&mut e, "3");
1482        assert_eq!(e.cursor().0, 2);
1483    }
1484
1485    #[test]
1486    fn set_shiftwidth_changes_indent_step() {
1487        let mut e = new("hello");
1488        // Default: shiftwidth = 2.
1489        run(&mut e, "set sw=4");
1490        assert_eq!(e.settings().shiftwidth, 4);
1491        // Indent uses the new value: `>>` prepends 4 spaces now.
1492        type_keys(&mut e, ">>");
1493        assert_eq!(e.buffer().lines()[0], "    hello");
1494    }
1495
1496    #[test]
1497    fn set_tabstop_stored() {
1498        let mut e = new("");
1499        run(&mut e, "set tabstop=4");
1500        assert_eq!(e.settings().tabstop, 4);
1501    }
1502
1503    #[test]
1504    fn set_ignorecase_affects_substitute() {
1505        let mut e = new("Hello");
1506        // Plain :s/h/X/ misses on the lowercase pattern.
1507        let effect = run(&mut e, "s/h/X/");
1508        assert_eq!(effect, ExEffect::Substituted { count: 0 });
1509        run(&mut e, "set ignorecase");
1510        assert!(e.settings().ignore_case);
1511        let effect = run(&mut e, "s/h/X/");
1512        assert_eq!(effect, ExEffect::Substituted { count: 1 });
1513        assert_eq!(e.buffer().lines()[0], "Xello");
1514    }
1515
1516    #[test]
1517    fn set_no_prefix_disables_boolean() {
1518        let mut e = new("x");
1519        run(&mut e, "set ic");
1520        assert!(e.settings().ignore_case);
1521        run(&mut e, "set noic");
1522        assert!(!e.settings().ignore_case);
1523    }
1524
1525    #[test]
1526    fn set_zero_shiftwidth_errors() {
1527        let mut e = new("x");
1528        match run(&mut e, "set sw=0") {
1529            ExEffect::Error(msg) => assert!(msg.contains("shiftwidth")),
1530            other => panic!("expected Error, got {other:?}"),
1531        }
1532    }
1533
1534    #[test]
1535    fn set_unknown_option_errors() {
1536        let mut e = new("x");
1537        match run(&mut e, "set bogus") {
1538            ExEffect::Error(msg) => assert!(msg.contains("bogus")),
1539            other => panic!("expected Error, got {other:?}"),
1540        }
1541    }
1542
1543    #[test]
1544    fn bare_set_reports_current_values() {
1545        let mut e = new("x");
1546        match run(&mut e, "set") {
1547            ExEffect::Info(msg) => {
1548                assert!(msg.contains("shiftwidth=2"));
1549                assert!(msg.contains("ignorecase=off"));
1550                assert!(msg.contains("wrap=off"));
1551            }
1552            other => panic!("expected Info, got {other:?}"),
1553        }
1554    }
1555
1556    #[test]
1557    fn set_wrap_flips_to_char_mode() {
1558        let mut e = new("x");
1559        run(&mut e, "set wrap");
1560        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Char);
1561    }
1562
1563    #[test]
1564    fn set_nowrap_resets() {
1565        let mut e = new("x");
1566        run(&mut e, "set wrap");
1567        run(&mut e, "set nowrap");
1568        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::None);
1569    }
1570
1571    #[test]
1572    fn set_linebreak_flips_to_word_mode() {
1573        let mut e = new("x");
1574        run(&mut e, "set linebreak");
1575        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
1576    }
1577
1578    #[test]
1579    fn set_wrap_after_linebreak_keeps_word_mode() {
1580        let mut e = new("x");
1581        run(&mut e, "set linebreak");
1582        run(&mut e, "set wrap");
1583        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
1584    }
1585
1586    #[test]
1587    fn set_nolinebreak_drops_to_char_when_wrap_on() {
1588        let mut e = new("x");
1589        run(&mut e, "set linebreak");
1590        run(&mut e, "set nolinebreak");
1591        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Char);
1592    }
1593
1594    #[test]
1595    fn foldsyntax_applies_host_supplied_ranges() {
1596        let mut e = new("a\nb\nc\nd\ne");
1597        e.set_syntax_fold_ranges(vec![(0, 2), (3, 4)]);
1598        match run(&mut e, "foldsyntax") {
1599            ExEffect::Info(msg) => assert!(msg.contains("2 fold")),
1600            other => panic!("expected Info, got {other:?}"),
1601        }
1602        let folds = e.buffer().folds();
1603        assert_eq!(folds.len(), 2);
1604        assert!(folds.iter().any(|f| f.start_row == 0 && f.end_row == 2));
1605        assert!(folds.iter().any(|f| f.start_row == 3 && f.end_row == 4));
1606    }
1607
1608    #[test]
1609    fn foldsyntax_no_ranges_reports_info() {
1610        let mut e = new("a\nb");
1611        match run(&mut e, "foldsyntax") {
1612            ExEffect::Info(msg) => assert!(msg.contains("no syntax block")),
1613            other => panic!("expected Info, got {other:?}"),
1614        }
1615    }
1616
1617    #[test]
1618    fn foldsyntax_short_alias() {
1619        let mut e = new("a\nb\nc");
1620        e.set_syntax_fold_ranges(vec![(0, 2)]);
1621        assert!(matches!(run(&mut e, "folds"), ExEffect::Info(_)));
1622        assert_eq!(e.buffer().folds().len(), 1);
1623    }
1624
1625    #[test]
1626    fn foldindent_creates_fold_for_indented_block() {
1627        let mut e = new("SELECT *\n  FROM t\n  WHERE x = 1\nORDER BY id");
1628        match run(&mut e, "foldindent") {
1629            ExEffect::Info(msg) => assert!(msg.contains("1 fold")),
1630            other => panic!("expected Info, got {other:?}"),
1631        }
1632        let folds = e.buffer().folds();
1633        assert_eq!(folds.len(), 1);
1634        assert_eq!(folds[0].start_row, 0);
1635        assert_eq!(folds[0].end_row, 2);
1636        assert!(folds[0].closed);
1637    }
1638
1639    #[test]
1640    fn foldindent_no_blocks_reports_info() {
1641        let mut e = new("a\nb\nc");
1642        match run(&mut e, "foldindent") {
1643            ExEffect::Info(msg) => assert!(msg.contains("no indented blocks")),
1644            other => panic!("expected Info, got {other:?}"),
1645        }
1646        assert!(e.buffer().folds().is_empty());
1647    }
1648
1649    #[test]
1650    fn foldindent_handles_nested_blocks() {
1651        let mut e = new("outer\n  mid\n    inner1\n    inner2\n  back\noutmost");
1652        run(&mut e, "foldindent");
1653        let folds = e.buffer().folds();
1654        // Outer block 0..=4 + inner block 1..=3 (mid → inner runs).
1655        assert_eq!(folds.len(), 2);
1656        assert_eq!(folds[0].start_row, 0);
1657        assert_eq!(folds[0].end_row, 4);
1658        assert_eq!(folds[1].start_row, 1);
1659        assert_eq!(folds[1].end_row, 3);
1660    }
1661
1662    #[test]
1663    fn foldindent_skips_blanks_inside_block() {
1664        let mut e = new("head\n  body1\n\n  body2\nfoot");
1665        run(&mut e, "foldindent");
1666        let folds = e.buffer().folds();
1667        assert_eq!(folds.len(), 1);
1668        assert_eq!(folds[0].start_row, 0);
1669        assert_eq!(folds[0].end_row, 3);
1670    }
1671
1672    #[test]
1673    fn foldindent_short_alias() {
1674        let mut e = new("a\n  b\nc");
1675        assert!(matches!(run(&mut e, "foldi"), ExEffect::Info(_)));
1676        assert_eq!(e.buffer().folds().len(), 1);
1677    }
1678
1679    #[test]
1680    fn read_file_inserts_below_current_row() {
1681        // Write a temp file with two rows.
1682        let dir = std::env::temp_dir();
1683        let path = dir.join(format!("hjkl_read_{}.sql", std::process::id()));
1684        std::fs::write(&path, "SELECT 1;\nSELECT 2;\n").unwrap();
1685        let mut e = new("alpha\nbeta");
1686        e.jump_cursor(0, 0);
1687        let cmd = format!("r {}", path.display());
1688        assert_eq!(run(&mut e, &cmd), ExEffect::Ok);
1689        assert_eq!(
1690            e.buffer().lines(),
1691            vec![
1692                "alpha".to_string(),
1693                "SELECT 1;".into(),
1694                "SELECT 2;".into(),
1695                "beta".into(),
1696            ]
1697        );
1698        // Cursor sits on the first inserted row.
1699        assert_eq!(e.cursor(), (1, 0));
1700        std::fs::remove_file(&path).ok();
1701    }
1702
1703    #[test]
1704    fn shell_filter_replaces_range() {
1705        let mut e = new("c\nb\na");
1706        // `:%!sort` reorders the whole buffer alphabetically.
1707        assert_eq!(run(&mut e, "%!sort"), ExEffect::Ok);
1708        assert_eq!(
1709            e.buffer().lines(),
1710            vec!["a".to_string(), "b".into(), "c".into()]
1711        );
1712    }
1713
1714    #[test]
1715    fn shell_filter_partial_range() {
1716        let mut e = new("head\ngamma\nbeta\nalpha\ntail");
1717        // `:2,4!sort` should reorder rows 2..=4 only.
1718        run(&mut e, "2,4!sort");
1719        assert_eq!(
1720            e.buffer().lines(),
1721            vec![
1722                "head".to_string(),
1723                "alpha".into(),
1724                "beta".into(),
1725                "gamma".into(),
1726                "tail".into(),
1727            ]
1728        );
1729    }
1730
1731    #[test]
1732    fn shell_filter_undo_restores() {
1733        let mut e = new("c\nb\na");
1734        let before: Vec<String> = e.buffer().lines().to_vec();
1735        run(&mut e, "%!sort");
1736        crate::vim::do_undo(&mut e);
1737        assert_eq!(e.buffer().lines(), before);
1738    }
1739
1740    #[test]
1741    fn shell_command_no_range_returns_info() {
1742        let mut e = new("buffer stays put");
1743        match run(&mut e, "!echo from-shell") {
1744            ExEffect::Info(msg) => assert!(msg.contains("from-shell")),
1745            other => panic!("expected Info, got {other:?}"),
1746        }
1747        // Buffer unchanged.
1748        assert_eq!(e.buffer().lines()[0], "buffer stays put");
1749    }
1750
1751    #[test]
1752    fn shell_filter_failing_command_errors() {
1753        let mut e = new("a\nb");
1754        match run(&mut e, "%!exit 5") {
1755            ExEffect::Error(msg) => assert!(msg.contains("exited 5")),
1756            other => panic!("expected Error, got {other:?}"),
1757        }
1758    }
1759
1760    #[test]
1761    fn shell_bang_empty_command_errors() {
1762        let mut e = new("a");
1763        match run(&mut e, "!") {
1764            ExEffect::Error(msg) => assert!(msg.contains("shell command")),
1765            other => panic!("expected Error, got {other:?}"),
1766        }
1767    }
1768
1769    #[test]
1770    fn read_bang_inserts_command_stdout() {
1771        let mut e = new("alpha\nbeta");
1772        e.jump_cursor(0, 0);
1773        // `echo` is portable — outputs a trailing newline that
1774        // apply_read_file strips.
1775        assert_eq!(run(&mut e, "r !echo hello"), ExEffect::Ok);
1776        assert_eq!(
1777            e.buffer().lines(),
1778            vec!["alpha".to_string(), "hello".into(), "beta".into()]
1779        );
1780    }
1781
1782    #[test]
1783    fn read_bang_failing_command_errors() {
1784        let mut e = new("hi");
1785        match run(&mut e, "r !exit 7") {
1786            ExEffect::Error(msg) => assert!(msg.contains("exited 7")),
1787            other => panic!("expected Error, got {other:?}"),
1788        }
1789    }
1790
1791    #[test]
1792    fn read_bang_empty_command_errors() {
1793        let mut e = new("hi");
1794        match run(&mut e, "r !") {
1795            ExEffect::Error(msg) => assert!(msg.contains("shell command")),
1796            other => panic!("expected Error, got {other:?}"),
1797        }
1798    }
1799
1800    #[test]
1801    fn read_file_alias_read_works() {
1802        let dir = std::env::temp_dir();
1803        let path = dir.join(format!("hjkl_read_alias_{}.sql", std::process::id()));
1804        std::fs::write(&path, "x").unwrap();
1805        let mut e = new("");
1806        let cmd = format!("read {}", path.display());
1807        run(&mut e, &cmd);
1808        assert_eq!(e.buffer().lines(), vec!["".to_string(), "x".into()]);
1809        std::fs::remove_file(&path).ok();
1810    }
1811
1812    #[test]
1813    fn read_file_missing_path_errors() {
1814        let mut e = new("a");
1815        match run(&mut e, "r /nonexistent/path/sqeel_test_xyzzy") {
1816            ExEffect::Error(msg) => assert!(msg.contains("cannot read")),
1817            other => panic!("expected Error, got {other:?}"),
1818        }
1819    }
1820
1821    #[test]
1822    fn read_file_undo_restores() {
1823        let dir = std::env::temp_dir();
1824        let path = dir.join(format!("hjkl_read_undo_{}.sql", std::process::id()));
1825        std::fs::write(&path, "ins\n").unwrap();
1826        let mut e = new("a\nb");
1827        e.jump_cursor(0, 0);
1828        run(&mut e, &format!("r {}", path.display()));
1829        assert_eq!(e.buffer().lines().len(), 3);
1830        crate::vim::do_undo(&mut e);
1831        assert_eq!(e.buffer().lines(), vec!["a".to_string(), "b".into()]);
1832        std::fs::remove_file(&path).ok();
1833    }
1834
1835    #[test]
1836    fn unknown_command() {
1837        let mut e = new("");
1838        match run(&mut e, "blargh") {
1839            ExEffect::Unknown(cmd) => assert_eq!(cmd, "blargh"),
1840            other => panic!("expected Unknown, got {other:?}"),
1841        }
1842    }
1843
1844    #[test]
1845    fn bad_substitute_pattern() {
1846        let mut e = new("hi");
1847        match run(&mut e, "s/[unterminated/foo/") {
1848            ExEffect::Error(_) => {}
1849            other => panic!("expected Error, got {other:?}"),
1850        }
1851    }
1852
1853    #[test]
1854    fn substitute_escaped_separator() {
1855        let mut e = new("a/b/c");
1856        let effect = run(&mut e, "s/\\//-/g");
1857        assert_eq!(effect, ExEffect::Substituted { count: 2 });
1858        assert_eq!(e.buffer().lines()[0], "a-b-c");
1859    }
1860
1861    #[test]
1862    fn global_delete_drops_matching_rows() {
1863        let mut e = new("keep1\nDROP1\nkeep2\nDROP2\nkeep3");
1864        let effect = run(&mut e, "g/DROP/d");
1865        assert_eq!(effect, ExEffect::Substituted { count: 2 });
1866        assert_eq!(
1867            e.buffer().lines(),
1868            &[
1869                "keep1".to_string(),
1870                "keep2".to_string(),
1871                "keep3".to_string()
1872            ]
1873        );
1874    }
1875
1876    #[test]
1877    fn global_negated_drops_non_matching_rows() {
1878        let mut e = new("keep1\nother\nkeep2");
1879        let effect = run(&mut e, "v/keep/d");
1880        assert_eq!(effect, ExEffect::Substituted { count: 1 });
1881        assert_eq!(
1882            e.buffer().lines(),
1883            &["keep1".to_string(), "keep2".to_string()]
1884        );
1885    }
1886
1887    #[test]
1888    fn global_with_regex_pattern() {
1889        let mut e = new("foo bar\nbaz qux\nfoo baz\nbaz");
1890        // Drop lines starting with "foo".
1891        let effect = run(&mut e, r"g/^foo/d");
1892        assert_eq!(effect, ExEffect::Substituted { count: 2 });
1893        assert_eq!(
1894            e.buffer().lines(),
1895            &["baz qux".to_string(), "baz".to_string()]
1896        );
1897    }
1898
1899    #[test]
1900    fn global_no_matches_reports_zero() {
1901        let mut e = new("hello\nworld");
1902        let effect = run(&mut e, "g/xyz/d");
1903        assert_eq!(effect, ExEffect::Substituted { count: 0 });
1904        assert_eq!(e.buffer().lines().len(), 2);
1905    }
1906
1907    #[test]
1908    fn global_unsupported_command_errors_out() {
1909        let mut e = new("foo\nbar");
1910        let effect = run(&mut e, "g/foo/p");
1911        assert!(matches!(effect, ExEffect::Error(_)));
1912    }
1913}