1use crate::editor::Editor;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ExEffect {
13 None,
15 Save,
17 Quit { force: bool, save: bool },
19 Unknown(String),
21 Substituted { count: usize },
23 Ok,
26 Info(String),
28 Error(String),
30}
31
32pub 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 let (range, cmd) = match parse_range(cmd, editor) {
44 Ok(pair) => pair,
45 Err(e) => return ExEffect::Error(e),
46 };
47
48 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 if let Some(r) = range {
61 editor.goto_line(r.start_one_based());
62 return ExEffect::Ok;
63 }
64 }
65
66 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 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 if let Some(rest) = cmd.strip_prefix("sort").or_else(|| cmd.strip_prefix("sor")) {
110 return apply_sort(editor, range, rest);
111 }
112
113 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 if let Some((negate, rest)) = parse_global_prefix(cmd) {
129 return apply_global(editor, range, rest, negate);
130 }
131
132 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 if cmd == "d" {
147 return apply_delete_range(editor, range);
148 }
149
150 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 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
167fn 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
184fn 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 while j < total && blank[j] {
211 j += 1;
212 }
213 if j >= total || indents[j] <= head_indent {
214 i += 1;
215 continue;
216 }
217 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 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
243fn 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 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 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
352fn 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 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 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 editor.jump_cursor(row + 1, 0);
416 editor.mark_dirty_after_ex();
417 ExEffect::Ok
418}
419
420#[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#[derive(Debug, Clone, Copy)]
455enum Address {
456 Number(usize), Current,
458 Last,
459 Mark(char),
460}
461
462fn 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
491fn 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
508fn 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
532fn 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 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
572fn 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
588fn 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 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 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
670fn 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
698fn 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 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 match editor.settings().wrap {
754 hjkl_buffer::Wrap::None => hjkl_buffer::Wrap::None,
755 _ => hjkl_buffer::Wrap::Char,
756 }
757 };
758 }
759 "foldenable" | "fen" => {}
762 other => return Err(format!("unknown :set option `{other}`")),
763 }
764 Ok(())
765}
766
767fn 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 let mut slice: Vec<String> = all_lines[scope.start..=bot].to_vec();
799 if numeric {
800 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 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
835fn 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
861fn 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
888fn 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
909fn 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 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
945fn 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
982fn 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 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
1013fn 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 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 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(®ex, line, &sub.replacement, sub.global);
1045 *line = replaced;
1046 count += n;
1047 }
1048
1049 if count == 0 {
1050 editor.undo_stack.pop();
1052 return Ok(0);
1053 }
1054
1055 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
1065fn regex_replace(
1069 regex: ®ex::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
1088fn 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 out.push_str("$0");
1106 } else {
1107 out.push(c);
1108 }
1109 }
1110 out
1111}
1112
1113impl<'a> Editor<'a> {
1114 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 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 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 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 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 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 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 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 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 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 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 run(&mut e, "set sw=4");
1490 assert_eq!(e.settings().shiftwidth, 4);
1491 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 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 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 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 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 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 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 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 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 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}