Skip to main content

strop_engine/editor/normal/
ex.rs

1//! normal/ex.rs — the ex command line: ranges, substitute, :w/:q family.
2
3use crate::editor::Editor;
4
5use crate::editor::Register;
6
7impl Editor {
8    /// Ex-completion candidates for the pending prefix (name, doc).
9    pub fn ex_candidates(&self) -> Vec<(&'static str, &'static str)> {
10        let Some(prefix) = self.pending.text().strip_prefix(':') else {
11            return Vec::new();
12        };
13        if prefix.contains(' ') {
14            return Vec::new();
15        }
16        super::EX_COMMANDS
17            .iter()
18            .filter(|(name, _)| name.starts_with(prefix))
19            .copied()
20            .collect()
21    }
22
23    /// Tab on the ex line: cycle the completion candidates — command
24    /// names for a bare prefix; remote hosts/paths for a file
25    /// command's `ssh://` operand (editor/remote_completion.rs).
26    pub(super) fn ex_tab_complete(&mut self) {
27        if self.remote_completion_tab() {
28            return;
29        }
30        let cands = self.ex_candidates();
31        if cands.is_empty() {
32            return;
33        }
34        let prefix = self.pending.text().strip_prefix(':').unwrap_or("");
35        let next = cands
36            .iter()
37            .position(|(name, _)| *name == prefix)
38            .map_or(cands[0].0, |i| cands[(i + 1) % cands.len()].0);
39        self.feed_pending_event(crate::editor::pending::PendingEvent::CompleteEx(
40            next.to_owned(),
41        ));
42    }
43
44    /// Parse a leading ex range: `%`, `.`, `$`, `N`, `N,M`, with
45    /// +/- offsets. Returns 0-indexed inclusive line bounds + the
46    /// remaining command text, or None when no range leads.
47    fn parse_ex_range<'a>(&self, cmdline: &'a str) -> (Option<(usize, usize)>, &'a str) {
48        let buf = self.buf();
49        let last = buf.last_content_line();
50        let cur = buf.line_of(self.head());
51        let addr = |tok: &str| -> Option<(usize, usize)> {
52            // one address + the bytes it consumed
53            match tok.as_bytes().first()? {
54                b'%' => Some((0, 1)),
55                b'.' => Some((cur, 1)),
56                b'$' => Some((last, 1)),
57                b if b.is_ascii_digit() => {
58                    let n: usize = tok
59                        .chars()
60                        .take_while(|c| c.is_ascii_digit())
61                        .collect::<String>()
62                        .parse()
63                        .ok()?;
64                    Some((n.saturating_sub(1).min(last), n.to_string().len()))
65                }
66                _ => None,
67            }
68        };
69        let (mut first, mut used) = match addr(cmdline) {
70            Some(v) => v,
71            None => return (None, cmdline),
72        };
73        let mut second = None;
74        if cmdline.as_bytes().get(used) == Some(&b',') {
75            match addr(&cmdline[used + 1..]) {
76                Some((l2, u2)) => {
77                    second = Some(l2);
78                    used += 1 + u2;
79                }
80                None => return (None, cmdline),
81            }
82        }
83        // +/- offsets trail an address (:+3, :-2, :.-1,$-1)
84        let whole = cmdline[..used].to_string();
85        let mut tail = &cmdline[used..];
86        let apply_off = |line: usize, tail: &str| -> (usize, usize) {
87            let b = tail.as_bytes();
88            let mut i = 0;
89            let mut line = line;
90            while i < tail.len() && (b[i] == b'+' || b[i] == b'-') {
91                let neg = b[i] == b'-';
92                i += 1;
93                let digits: String = tail[i..]
94                    .chars()
95                    .take_while(|c| c.is_ascii_digit())
96                    .collect();
97                let n: usize = if digits.is_empty() {
98                    1
99                } else {
100                    digits.parse().unwrap_or(1)
101                };
102                i += digits.len();
103                line = if neg {
104                    line.saturating_sub(n)
105                } else {
106                    (line + n).min(last)
107                };
108            }
109            (line, i)
110        };
111        let (f, fu) = apply_off(first, tail);
112        first = f;
113        tail = &tail[fu..];
114        let mut last_line = second.unwrap_or(first);
115        if second.is_some() {
116            let (l, lu) = apply_off(last_line, tail);
117            last_line = l;
118            tail = &tail[lu..];
119        }
120        if whole == "%" {
121            return (Some((0, last)), tail);
122        }
123        if first > last_line {
124            return (None, cmdline); // backwards range: vim errors
125        }
126        (Some((first.min(last), last_line.min(last))), tail)
127    }
128
129    /// Ranged commands: `:N` alone jumps; `d`/`y` delete/yank the
130    /// lines; `s/a/b/[g]` substitutes (LITERAL pattern — vim's regex
131    /// substitute is a documented deviation until 0016's grammar work).
132    fn run_ranged_ex(&mut self, range: (usize, usize), rest: &str) {
133        let (lo, hi) = range;
134        if let Some(argument) = rest.strip_prefix("fs ") {
135            self.run_filesystem_ex(argument, Some(range));
136            return;
137        }
138        if rest.is_empty() {
139            // :N — goto line
140            let s = self.buf().line_start(lo);
141            self.set_head(s);
142            self.clamp_cursor();
143            self.scroll_to_cursor(self.view_rows());
144            return;
145        }
146        match rest {
147            "d" | "d!" => {
148                let s = self.buf().line_start(lo);
149                let e = if hi + 1 < self.buf().len_lines() {
150                    self.buf().line_start(hi + 1)
151                } else {
152                    self.buf().len_bytes()
153                };
154                let text = self.buf().text().byte_slice(s..e).to_string();
155                let mut register = Register::linewise(text);
156                register.file_provenance = self.capture_filename_register(
157                    [(strop_core::Range::charwise(s, e), true)],
158                    true,
159                    false,
160                );
161                self.set_register(None, register);
162                self.tx_begin();
163                self.filename_delete_hint(strop_core::Range::charwise(s, e), true);
164                self.buf_mut().delete(strop_core::Range::charwise(s, e));
165                self.clear_filename_hint();
166                self.tx_commit();
167                self.set_head(self.buf().clamp_boundary(s));
168                self.clamp_cursor();
169                self.message = format!("{} lines deleted", hi - lo + 1);
170            }
171            "y" => {
172                let s = self.buf().line_start(lo);
173                let e = if hi + 1 < self.buf().len_lines() {
174                    self.buf().line_start(hi + 1)
175                } else {
176                    self.buf().len_bytes()
177                };
178                let text = self.buf().text().byte_slice(s..e).to_string();
179                let mut register = Register::linewise(text);
180                register.file_provenance = self.capture_filename_register(
181                    [(strop_core::Range::charwise(s, e), true)],
182                    false,
183                    false,
184                );
185                self.set_register(None, register);
186                self.message = format!("{} lines yanked", hi - lo + 1);
187            }
188            _ if rest.starts_with("s/") => self.substitute_range(lo, hi, &rest[2..]),
189            _ => self.message = format!("unsupported ranged command: {rest}"),
190        }
191    }
192
193    /// `:[range]s/pat/repl/[g]` — literal pattern, vim's flag letter g.
194    fn substitute_range(&mut self, lo: usize, hi: usize, spec: &str) {
195        let parts: Vec<&str> = spec.split('/').collect();
196        if parts.len() < 2 {
197            self.message = ":s needs /pat/repl/".into();
198            return;
199        }
200        let (pat, repl) = (parts[0], parts[1]);
201        let global = parts.get(2).is_some_and(|f| f.contains('g'));
202        if pat.is_empty() {
203            self.message = "empty pattern".into();
204            return;
205        }
206        let s0 = self.buf().line_start(lo);
207        let e0 = self.buf().line_end(hi);
208        let text = self.buf().text().byte_slice(s0..e0).to_string();
209        let mut edits = Vec::new();
210        let mut offset = s0;
211        for line in text.split_inclusive('\n') {
212            for (start, _) in line.match_indices(pat) {
213                edits.push(strop_core::Replacement::new(
214                    strop_core::Range::charwise(offset + start, offset + start + pat.len()),
215                    repl,
216                ));
217                if !global {
218                    break;
219                }
220            }
221            offset += line.len();
222        }
223        let hits = edits.len();
224        if hits == 0 {
225            self.message = format!("pattern not found: {pat}");
226            return;
227        }
228        if let Err(error) = self.apply(
229            self.current(),
230            self.buf().revision(),
231            crate::editor::transact::ChangeSet {
232                edits,
233                undo_open: false,
234            },
235        ) {
236            self.message = format!("substitution failed: {error}");
237            return;
238        }
239        self.set_head(self.buf().clamp_boundary(s0));
240        self.clamp_cursor();
241        let end = e0
242            .saturating_add_signed((repl.len() as isize - pat.len() as isize) * hits as isize)
243            .min(self.buf().len_bytes());
244        self.flash(strop_core::Range::charwise(s0, end));
245        self.message = format!("{hits} substitution{}", if hits == 1 { "" } else { "s" });
246    }
247
248    pub(crate) fn run_ex(&mut self, cmdline: &str) {
249        // vim ex ranges: [%, N, N.M, ., $, +/-offsets] prefix the
250        // command. Bare :N is goto-line.
251        let (range, rest) = self.parse_ex_range(cmdline);
252        if let Some(range) = range {
253            self.run_ranged_ex(range, rest);
254            return;
255        }
256        let (cmd, arg) = cmdline.split_once(' ').unwrap_or((cmdline, ""));
257        if cmd == "fs" {
258            self.run_filesystem_ex(arg, None);
259            return;
260        }
261        if matches!(cmd, "browse" | "filter") {
262            let result = if cmd == "browse" {
263                self.browse_directory(arg.trim())
264            } else {
265                self.filter_directory(arg.to_owned())
266            };
267            if let Err(error) = result {
268                self.message = error;
269            }
270            return;
271        }
272        if self.run_remote_ex(cmd, arg) {
273            return;
274        }
275        match cmd {
276            _ if cmdline.starts_with('!') => self.shell_run(&cmdline[1..]),
277            "w" | "w!" if self.collections.contains_key(&self.current()) => {
278                self.collection_save((!arg.is_empty()).then(|| arg.into()), cmd == "w!", false);
279            }
280            "w" | "w!" => {
281                // vim: readonly buffers refuse plain :w (surfaces, :view);
282                // :w! forces through the mutation boundary's rule
283                // Container files have no write path (0037 DC1b): refuse
284                // both forms — never a local-path fallback, never w!.
285                if matches!(
286                    self.cur().source,
287                    crate::editor::document::DocumentSource::Container { .. }
288                ) {
289                    self.message =
290                        "container files are read-only (0037 DC1b); no in-container save".into();
291                    return;
292                }
293                if self.buf().readonly && cmd != "w!" && self.remote_file().is_none() {
294                    let name = self.buf().name.as_deref().unwrap_or("readonly buffer");
295                    self.message = format!("{name}: readonly — :w! to force");
296                    return;
297                }
298                self.request_save((!arg.is_empty()).then(|| arg.into()), cmd == "w!", false);
299            }
300            "wq" | "wq!" if self.collections.contains_key(&self.current()) => {
301                self.collection_save(None, cmd == "wq!", true);
302            }
303            "wq" | "wq!" => {
304                if matches!(
305                    self.cur().source,
306                    crate::editor::document::DocumentSource::Container { .. }
307                ) {
308                    self.message =
309                        "container files are read-only (0037 DC1b); no in-container save".into();
310                    return;
311                }
312                self.request_save((!arg.is_empty()).then(|| arg.into()), cmd == "wq!", true);
313            }
314            "set" => {
315                // vim's option surface, narrowly: ro/noro only for now
316                match arg {
317                    "ro" | "readonly" => {
318                        self.buf_mut().readonly = true;
319                        self.message = "readonly".into();
320                    }
321                    "noro" | "noreadonly" => {
322                        if self.directory().is_some_and(|source| {
323                            source.draft.as_ref().is_none_or(|draft| !draft.editable())
324                        }) {
325                            self.message = "Directory listings are read-only; use :fs edit for filename drafts".into();
326                            return;
327                        }
328                        if matches!(
329                            self.cur().source,
330                            crate::editor::DocumentSource::Container { .. }
331                        ) {
332                            self.message = "container files are read-only by policy".into();
333                            return;
334                        }
335                        if self.remote_file().is_some() && !self.remote_edit_authorized() {
336                            self.message =
337                                "remote file is read-only; use :remote edit first".into();
338                        } else {
339                            self.buf_mut().readonly = false;
340                            self.message = "writable".into();
341                        }
342                    }
343                    _ => self.message = format!("unknown option: {arg}"),
344                }
345            }
346            "view" => {
347                // vim view: edit readonly — no arg marks the current
348                // buffer readonly
349                if arg.is_empty() {
350                    self.buf_mut().readonly = true;
351                    self.message = "readonly".into();
352                } else {
353                    self.request_user_open(
354                        arg,
355                        super::super::io::OpenIntent::Switch { readonly: true },
356                    );
357                }
358            }
359            "q" => {
360                self.close_pane_or_buffer(false);
361            }
362            "q!" => {
363                self.close_pane_or_buffer(true);
364            }
365            "qa" | "qall" => self.quit_all(false),
366            "qa!" | "qall!" => self.quit_all(true),
367            _ if cmdline.starts_with("s/") => {
368                // :s without a range = the current line (vim)
369                let line = self.buf().line_of(self.head());
370                self.substitute_range(line, line, &cmdline[2..]);
371            }
372            "trust" => self.request_trust(),
373            "noh" => {
374                // nohlsearch: the persistent highlight drops (0001 §5.8)
375                self.last_search = None;
376            }
377            _ if cmdline.bytes().all(|b| b.is_ascii_digit()) && !cmdline.is_empty() => {
378                // :30 jumps to line 30 (vim); past EOF clamps to the last
379                // content line, never the phantom past a trailing newline
380                let n: usize = cmdline.parse().unwrap_or(1);
381                let mut last = self.buf().len_lines().saturating_sub(1);
382                if self.buf().line_start(last) >= self.buf().len_bytes() {
383                    last = last.saturating_sub(1);
384                }
385                self.push_jump(); // :N is a jump — record before moving
386                self.set_head(self.buf().line_start(n.saturating_sub(1).min(last)));
387                self.clamp_cursor();
388            }
389            "vs" | "vsplit" => self.split(true, if arg.is_empty() { None } else { Some(arg) }),
390            "sp" | "split" => self.split(false, if arg.is_empty() { None } else { Some(arg) }),
391            "help" | "h" => self.open_help_topic(arg),
392            "jumps" => self.open_jumps_picker(),
393            "search-options" => self.open_search_options(),
394            "tab-size" => self.tab_size_command(arg),
395            "indent-style" => self.indent_style_command(arg),
396            "apply-change" => self.review_apply_pub(),
397            "select-next" => self.occurrence_next_pub(),
398            "select-all" => self.occurrence_all_pub(),
399            "select-skip" => self.occurrence_skip_pub(),
400            "select-pop" => self.occurrence_pop_pub(),
401            "cancel-change" => self.review_cancel_pub(),
402            "collection" if arg == "source" => self.collection_open_source(),
403            "collection" if arg == "expand" => self.collection_context_step(true),
404            "collection" if arg == "contract" => self.collection_context_step(false),
405            "collection" => {
406                self.message = ":collection source — open the full source at the caret".into()
407            }
408            "symbols" => self.lsp_document_symbols_pub(),
409            "explain" => self.open_explain(),
410            "containers" => self.request_containers(),
411            "format" => self.lsp_format(),
412            "rename" => {
413                if arg.is_empty() {
414                    self.message = ":rename needs a new name".into();
415                } else {
416                    self.lsp_rename(arg);
417                }
418            }
419            "undo-change" => self.undo_last_change(),
420            "save-change" => self.save_changed_files_pub(),
421            "e" | "e!" => {
422                if self.buf().dirty && cmd == "e" {
423                    self.message = "unsaved changes — :e! to force".into();
424                } else if arg.is_empty() {
425                    if !self.refresh_current_resource() {
426                        self.message = "no file or directory to reload".into();
427                    }
428                } else {
429                    self.request_user_open(
430                        arg,
431                        super::super::io::OpenIntent::Switch { readonly: false },
432                    );
433                }
434            }
435            other => self.message = format!("unknown ex: :{other}"),
436        }
437    }
438}