Skip to main content

strop_engine/editor/normal/
pending.rs

1//! normal/pending.rs — prompt effects (R7): one reducer consumer,
2//! independent of the surface that opened the prompt. Editing,
3//! incsearch, acceptance and cancellation all live here; the surfaces
4//! only route keys to the shared `PendingInput`.
5
6use strop_core::Range;
7use strop_grammar::{self as grammar, Command, Parse};
8
9use crate::editor::pending::{
10    PendingEffect, PendingEvent, PromptContext, SearchOrigin, TextPrompt,
11};
12use crate::editor::{input::ParserState, Editor, Key, Mode};
13
14impl Editor {
15    /// A text line opened (`: / ? |`) with the typed entry state that
16    /// survived the crossing — counts, register, operator.
17    pub(crate) fn begin_text_line(&mut self, sigil: char, state: ParserState) {
18        self.cancel_pending();
19        let origin = SearchOrigin {
20            pane_index: self.active_pane,
21            pane: self.view().clone(),
22            revision: self.buf().revision(),
23        };
24        let context = match sigil {
25            ':' => PromptContext::Ex(origin),
26            '/' | '?' => PromptContext::Search {
27                origin,
28                state,
29                backward: sigil == '?',
30            },
31            '|' => {
32                if self.buf().readonly {
33                    self.message = "readonly buffer".into();
34                    return;
35                }
36                let visual = matches!(
37                    self.mode,
38                    Mode::Visual | Mode::VisualLine | Mode::VisualBlock
39                );
40                let range = if visual {
41                    let Some(range) = self.visual_range() else {
42                        return;
43                    };
44                    range
45                } else {
46                    let line = self.buf().line_of(self.head());
47                    Range::charwise(
48                        self.buf().line_start(line),
49                        self.buf().line_start(line + 1).min(self.buf().len_bytes()),
50                    )
51                };
52                PromptContext::Pipe {
53                    origin,
54                    range,
55                    visual,
56                }
57            }
58            _ => unreachable!("Walker only emits text-line sigils"),
59        };
60        self.pending.open(TextPrompt::new(context));
61    }
62
63    /// The saved origin still describes this editor: same pane slot,
64    /// same document incarnation, unchanged revision. Service results
65    /// and edits invalidate it; delivery-time checks are the backstop.
66    pub(crate) fn pending_origin_valid(&self, origin: &SearchOrigin) -> bool {
67        self.active_pane == origin.pane_index
68            && self
69                .panes
70                .get(origin.pane_index)
71                .is_some_and(|p| p.doc == origin.pane.doc)
72            && self
73                .docs
74                .get(origin.pane.doc)
75                .is_some_and(|d| d.buf.revision() == origin.revision)
76    }
77
78    fn restore_prompt_origin(&mut self, origin: &SearchOrigin) -> bool {
79        if !self.pending_origin_valid(origin) {
80            return false;
81        }
82        // Never clamp/normalize: those operations would change saved
83        // anchors, duplicate cursors or a deliberately parked viewport.
84        self.panes[origin.pane_index] = origin.pane.clone();
85        true
86    }
87
88    /// Abort the open prompt (if any), restoring its origin first.
89    /// Called before anything replaces the pane/document/revision the
90    /// prompt was opened against — never on rejected service results.
91    pub(crate) fn cancel_pending(&mut self) {
92        if let PendingEffect::Aborted(prompt) = self.pending.reduce(PendingEvent::Cancel) {
93            self.resolution.cancel_preview();
94            self.restore_prompt_origin(prompt.origin());
95        }
96    }
97
98    pub(crate) fn feed_pending(&mut self, key: Key) {
99        self.feed_pending_event(PendingEvent::Key(key));
100    }
101
102    /// The shared prompt entrypoint: keys, pastes and completion events
103    /// all reduce through the same owner.
104    pub(crate) fn feed_pending_event(&mut self, event: PendingEvent) {
105        if self
106            .pending
107            .prompt()
108            .is_some_and(|p| !self.pending_origin_valid(p.origin()))
109        {
110            self.cancel_pending();
111            self.message = "input cancelled: document or pane changed".into();
112            return;
113        }
114        match self.pending.reduce(event) {
115            PendingEffect::None | PendingEffect::ModeChanged => {}
116            PendingEffect::Edited => self.incsearch_jump(),
117            PendingEffect::CompleteEx => self.ex_tab_complete(),
118            PendingEffect::Repaint => self.needs_repaint = true,
119            PendingEffect::Rejected(error) => self.message = error.into(),
120            PendingEffect::Aborted(prompt) => {
121                self.restore_prompt_origin(prompt.origin());
122            }
123            PendingEffect::Accepted(prompt) => self.accept_prompt(prompt),
124        }
125    }
126
127    fn accept_prompt(&mut self, prompt: TextPrompt) {
128        if !self.restore_prompt_origin(prompt.origin()) {
129            self.message = "input cancelled: document or pane changed".into();
130            return;
131        }
132        match prompt.context() {
133            PromptContext::Ex(_) => self.run_ex(prompt.body()),
134            PromptContext::Pipe { range, visual, .. } => {
135                if self.buf().readonly {
136                    self.message = "readonly buffer".into();
137                    return;
138                }
139                self.pipe_run(range.start.get(), range.end.get(), prompt.body());
140                if *visual {
141                    self.mode = Mode::Normal;
142                }
143            }
144            PromptContext::Search { .. } => {
145                let command = match self.search_prompt_command(&prompt, true) {
146                    Ok(Some(command)) => command,
147                    Ok(None) => return,
148                    Err(error) => {
149                        self.message = error;
150                        return;
151                    }
152                };
153                if self.defer_resolution(
154                    &command,
155                    self.all_cursors(),
156                    super::super::resolution::ResolutionPurpose::Execute,
157                ) {
158                    return;
159                }
160                // Runtime query errors must be discovered for every
161                // cursor BEFORE dispatch changes last_search, history
162                // or a register.
163                if let Err(error) = self.search_prompt_heads(&prompt, &command) {
164                    self.message = error;
165                    return;
166                }
167                self.dispatch_grammar(&command);
168            }
169        }
170    }
171
172    /// The typed command a search prompt currently stands for. An
173    /// empty body with `repeat_empty` reuses the last compiled query
174    /// (vim: `/⏎` / `?⏎`); the count/register/operator stay those of
175    /// THIS entry. Direction comes from the prompt's own sigil.
176    pub(crate) fn search_prompt_command(
177        &self,
178        prompt: &TextPrompt,
179        repeat_empty: bool,
180    ) -> Result<Option<Command>, String> {
181        let Some((_, state)) = prompt.search() else {
182            return Ok(None);
183        };
184        let query = if prompt.body().is_empty() {
185            if !repeat_empty {
186                return Ok(None);
187            }
188            self.last_search
189                .as_ref()
190                .ok_or_else(|| "no previous search".to_string())?
191                .query
192                .clone()
193        } else {
194            grammar::CompiledQuery::compile(prompt.body(), false).map_err(|e| e.to_string())?
195        };
196        let target = if prompt.backward() == Some(true) {
197            grammar::Motion::SearchBackward(query)
198        } else {
199            grammar::Motion::Search(query)
200        };
201        Ok(Some(Command {
202            op: state.op,
203            register: state.register,
204            count: state.count(),
205            target: grammar::Target::Motion(target),
206            // Execution records the typed command for dot repeat.
207            keys: String::new(),
208        }))
209    }
210
211    /// Where every cursor of the saved origin lands for this command —
212    /// the exact execution resolver (`resolve_many`), so incsearch and
213    /// Enter can never disagree.
214    fn search_prompt_heads(
215        &self,
216        prompt: &TextPrompt,
217        command: &Command,
218    ) -> Result<Vec<usize>, String> {
219        let (origin, _) = prompt.search().expect("search context");
220        let heads = origin.pane.sels.heads();
221        let resolved = self.resolved_many(command, &heads)?;
222        Ok(heads
223            .into_iter()
224            .zip(resolved)
225            .map(|(head, hit)| {
226                hit.map_or(head, |hit| {
227                    self.clamp_pos(grammar::cursor_after(self.buf(), head, command, &hit))
228                })
229            })
230            .collect())
231    }
232
233    /// Live incsearch (vim parity): while a `/`/`?` prompt is open every
234    /// cursor tracks the pattern's match from the saved origin — typing
235    /// AND deleting re-resolve, all cursors at once. No match parks at
236    /// the origin (vim keeps position and reports E486).
237    pub(crate) fn incsearch_jump(&mut self) {
238        let Some(prompt) = self.pending.prompt().cloned() else {
239            return;
240        };
241        let Some((origin, _)) = prompt.search() else {
242            return;
243        };
244        if !self.pending_origin_valid(origin) {
245            return;
246        }
247        let command = self.search_prompt_command(&prompt, false);
248        self.restore_prompt_origin(origin);
249        let command = match command {
250            Ok(Some(command)) => command,
251            Ok(None) => {
252                self.resolution.cancel_preview();
253                return;
254            }
255            Err(error) => {
256                self.resolution.cancel_preview();
257                self.message = error;
258                return;
259            }
260        };
261        if self.defer_resolution(
262            &command,
263            origin.pane.sels.heads(),
264            super::super::resolution::ResolutionPurpose::IncSearch,
265        ) {
266            return;
267        }
268        match self.search_prompt_heads(&prompt, &command) {
269            Ok(heads) => {
270                let mut heads = heads.into_iter();
271                self.set_head(heads.next().expect("primary selection"));
272                self.sels_mut().set_extras(heads);
273                self.clamp_cursor();
274            }
275            Err(error) => self.message = error,
276        }
277    }
278
279    /// Pending search pattern (incsearch highlight), if any: the `/` or
280    /// `?` prompt's body. Pipe and ex bodies never misread as patterns.
281    pub fn search_pattern(&self) -> Option<&str> {
282        let prompt = self.pending.prompt()?;
283        prompt.search()?;
284        (!prompt.body().is_empty()).then_some(prompt.body())
285    }
286
287    /// vim Enter: [count] lines down, first non-blank. With the blame
288    /// gutter on, Enter dives into the line's commit instead (0011 §3).
289    pub fn enter_pub(&mut self) {
290        if self.dive_from_blame() {
291            return;
292        }
293        let n = self.walker.state.count1.unwrap_or(1);
294        let line = (self.buf().line_of(self.head()) + n).min(self.buf().last_content_line());
295        let s = self.buf().line_start(line);
296        let e = self.buf().line_end(line);
297        let mut p = s;
298        while p < e
299            && self
300                .buf()
301                .byte_at(p)
302                .is_some_and(|b| b == b' ' || b == b'\t')
303        {
304            p += 1;
305        }
306        self.set_head(p.min(e));
307        self.clamp_cursor();
308    }
309
310    pub fn run_motion(&mut self, keys: &str) {
311        match grammar::parse(keys) {
312            Parse::Complete(cmd) => self.move_cursor(&cmd),
313            Parse::QueryError(error) => self.message = error.to_string(),
314            Parse::Incomplete | Parse::Invalid => {}
315        }
316    }
317}