strop_engine/editor/normal/
ex.rs1use crate::editor::Editor;
4
5use crate::editor::Register;
6
7impl Editor {
8 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 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 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 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 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); }
126 (Some((first.min(last), last_line.min(last))), tail)
127 }
128
129 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 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 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 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 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 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 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 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 self.last_search = None;
376 }
377 _ if cmdline.bytes().all(|b| b.is_ascii_digit()) && !cmdline.is_empty() => {
378 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(); 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}