Skip to main content

cargo_spellcheck/action/
interactive.rs

1//! Interactive picking of replacements, contained in a suggestion.
2//!
3//! The result of that pick is a bandaid.
4
5use super::*;
6
7use crossterm;
8
9use crossterm::{
10    cursor,
11    event::{Event, KeyCode, KeyEvent, KeyModifiers},
12    style::{Attribute, Color, ContentStyle, PrintStyledContent, StyledContent},
13    terminal, QueueableCommand,
14};
15
16use std::io::stdout;
17
18const HELP: &str = r##"y - apply this suggestion
19n - do not apply the suggested correction
20q - quit; do not stage this hunk or any of the remaining ones
21d - do not apply this suggestion and skip the rest of the file
22g - select a suggestion to go to
23j - leave this hunk undecided, see next undecided hunk
24J - leave this hunk undecided, see next hunk
25e - manually edit the current hunk
26? - print help
27
28
29
30
31"##;
32
33/// Helper strict to assure we leave the terminals raw mode
34pub struct ScopedRaw;
35
36impl ScopedRaw {
37    /// Enter raw terminal mode.
38    ///
39    /// Must be left before using `log::info!(..)` or any other printing macros
40    /// or functions.
41    fn new() -> Result<Self> {
42        crossterm::terminal::enable_raw_mode()?;
43        Ok(Self)
44    }
45
46    /// Helper to restore the previous terminal state.
47    ///
48    /// Also called on `drop`.
49    pub fn restore_terminal() -> Result<()> {
50        crossterm::terminal::disable_raw_mode()?;
51        stdout()
52            .queue(crossterm::cursor::Show)?
53            .flush()
54            .wrap_err_with(|| eyre!("Failed to restore terminal"))
55    }
56}
57
58impl Drop for ScopedRaw {
59    fn drop(&mut self) {
60        let _ = Self::restore_terminal();
61    }
62}
63
64/// In which direction we should progress.
65#[derive(Debug, Clone, Copy)]
66enum Direction {
67    /// In order.
68    Forward,
69    /// Reverse order from the current position.
70    #[allow(unused)]
71    Backward,
72}
73
74/// The user picked something. This is the pick representation.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub(super) enum UserSelection {
77    /// This `BandAid` is going to be applied.
78    Replacement(BandAid),
79    /// Skip this suggestion and move on to the next suggestion.
80    Skip,
81    /// Jump to the previous suggestion.
82    Previous,
83    /// Print the help message and exit.
84    Help,
85    /// Skip the remaining fixes for the current file.
86    SkipFile,
87    /// continue as if whatever returned this was never called.
88    Nop,
89    /// Stop execution, forget all previous choices.
90    Abort,
91    /// Stop fixing chunks, move on to applying the ones chosen so far.
92    Quit,
93}
94
95/// Statefulness for the selection process
96#[derive(Debug)]
97struct State<'s, 't>
98where
99    't: 's,
100{
101    /// Which suggestion is operated upon.
102    pub suggestion: &'s Suggestion<'t>,
103    /// The content the user provided for the suggestion, if any.
104    pub custom_replacement: String,
105    pub cursor_offset: u16,
106    /// Back ticked original content
107    pub backticked_original: String,
108    /// Which index to show as highlighted.
109    pub pick_idx: usize,
110    /// Total number of pickable slots.
111    pub n_items: usize,
112}
113
114impl<'s, 't> From<&'s Suggestion<'t>> for State<'s, 't> {
115    fn from(suggestion: &'s Suggestion<'t>) -> Self {
116        Self {
117            suggestion,
118            custom_replacement: String::new(),
119            cursor_offset: 0,
120            // TODO only suggest this if this doesn't have spaces and/or parses with `ap_syntax`
121            // TODO and check the identifiers against everything we've seen in the codebase
122            // TODO this has a few issues though, that partial runs might be unaware of all `Ident`s
123            // TODO so there should probably be a strict mode for full runs, that checks the existence
124            // TODO and the default, partial mode that is more forgiving
125            backticked_original: format!(
126                "`{}`",
127                sub_chars(suggestion.chunk.as_str(), suggestion.range.clone())
128            ),
129            // start at a suggestion, not the custom field or ticked suggestion
130            pick_idx: 1_usize + usize::from(!suggestion.replacements.is_empty()),
131            // all items provided by the checkers plus the user provided
132            n_items: suggestion.replacements.len() + 2,
133        }
134    }
135}
136
137impl<'s, 't> State<'s, 't>
138where
139    't: 's,
140{
141    /// Selects the next line.
142    pub fn select_next(&mut self) {
143        self.pick_idx = (self.pick_idx + 1).rem_euclid(self.n_items);
144    }
145
146    /// Selects the previous line.
147    pub fn select_previous(&mut self) {
148        self.pick_idx = (self.pick_idx + self.n_items - 1).rem_euclid(self.n_items);
149    }
150
151    /// Select the custom line, which is by definition the last selectable.
152    pub fn select_custom(&mut self) {
153        self.pick_idx = 0;
154    }
155
156    /// Checks if the currently selected line is the custom entry.
157    pub fn is_custom_entry(&self) -> bool {
158        self.pick_idx == 0
159    }
160
161    pub fn is_ticked_entry(&self) -> bool {
162        self.pick_idx == 1
163    }
164
165    /// Convert the replacement to a `BandAid`
166    pub fn to_bandaid(&self) -> BandAid {
167        if self.is_ticked_entry() {
168            BandAid::from((self.backticked_original.clone(), &self.suggestion.span))
169        } else if self.is_custom_entry() {
170            BandAid::from((self.custom_replacement.clone(), &self.suggestion.span))
171        } else {
172            let replacement = self
173                .suggestion
174                .replacements
175                .get(self.pick_idx.saturating_sub(2)) // there is a static offset of 2
176                .expect("User Pick index is never out of bounds. qed");
177            BandAid::from((replacement.to_owned(), &self.suggestion.span))
178        }
179    }
180}
181
182/// The selection of used suggestion replacements.
183#[derive(Debug, Clone, Default)]
184pub struct UserPicked {
185    /// Associates the bandaids to a content origin, or path respectively.
186    pub bandaids: indexmap::IndexMap<ContentOrigin, Vec<BandAid>>,
187}
188
189impl UserPicked {
190    /// Count the number of suggestions across all files in total.
191    pub fn total_count(&self) -> usize {
192        self.bandaids
193            .iter()
194            .map(|(_origin, bandaids)| bandaids.len())
195            .sum()
196    }
197
198    /// Are there any user picks?
199    pub fn is_empty(&self) -> bool {
200        !self
201            .bandaids
202            .iter()
203            .any(|(_origin, bandaids)| !bandaids.is_empty())
204    }
205
206    /// Apply a single `BandAid`
207    pub fn add_bandaid(&mut self, origin: &ContentOrigin, bandaid: BandAid) {
208        self.bandaids
209            .entry(origin.clone())
210            .or_insert_with(|| Vec::with_capacity(10))
211            .push(bandaid);
212    }
213
214    /// Apply multiple bandaids.
215    pub fn add_bandaids<I>(&mut self, origin: &ContentOrigin, fixes: I)
216    where
217        I: IntoIterator<Item = BandAid>,
218    {
219        let iter = fixes.into_iter();
220        self.bandaids
221            .entry(origin.clone())
222            .or_insert_with(|| Vec::with_capacity(iter.size_hint().0))
223            .extend(iter);
224    }
225
226    /// Join two `UserPick`s.
227    pub fn extend(&mut self, other: Self) {
228        self.bandaids.extend(other.bandaids);
229    }
230
231    /// Provide a replacement that was not provided by the backend
232    fn enter_custom_replacement(
233        &self,
234        state: &mut State,
235        event: KeyEvent,
236    ) -> Result<UserSelection> {
237        let KeyEvent {
238            code, modifiers, ..
239        } = event;
240
241        let length = state.custom_replacement.len() as u16;
242        match code {
243            KeyCode::Left => state.cursor_offset = state.cursor_offset.saturating_sub(1),
244            KeyCode::Right => state.cursor_offset = (state.cursor_offset + 1).min(length),
245            KeyCode::Up => {
246                state.cursor_offset = length;
247                state.select_next();
248            }
249            KeyCode::Down => {
250                state.cursor_offset = length;
251                state.select_previous();
252            }
253            KeyCode::Backspace => {
254                if state.cursor_offset > 0 {
255                    state.cursor_offset -= 1;
256                    state
257                        .custom_replacement
258                        .remove(state.cursor_offset as usize);
259                }
260            }
261            KeyCode::Enter => {
262                let bandaid = state.to_bandaid();
263                return Ok(UserSelection::Replacement(bandaid));
264            }
265            KeyCode::Esc => return Ok(UserSelection::Abort),
266            KeyCode::Char('c') if modifiers == KeyModifiers::CONTROL => {
267                return Ok(UserSelection::Abort);
268            }
269            KeyCode::Char(c) => {
270                state
271                    .custom_replacement
272                    .insert(state.cursor_offset as usize, c);
273                state.cursor_offset += 1;
274            }
275            _ => {}
276        }
277
278        Ok(UserSelection::Nop)
279    }
280
281    /// Only print the list of replacements to the user.
282    // initial thougth was to show a horizontal list of replacements, navigate left/ right
283    // by using the arrow keys
284    // .. suggestion0 [suggestion1] suggestion2 suggestion3 ..
285    // arrow left
286    // .. suggestion1 [suggestion2] suggestion3 suggestion4 ..
287    // but now it's only a very simple list
288    fn print_replacements_list(&self, state: &mut State) -> Result<()> {
289        let mut stdout = stdout();
290
291        let mut tick = ContentStyle::new();
292        tick.foreground_color = Some(Color::Green);
293        tick.attributes = Attribute::Bold.into();
294
295        let mut highlight = ContentStyle::new();
296        highlight.background_color = Some(Color::Black);
297        highlight.foreground_color = Some(Color::Green);
298        highlight.attributes = Attribute::Bold.into();
299
300        let mut others = ContentStyle::new();
301        others.background_color = Some(Color::Black);
302        others.foreground_color = Some(Color::Blue);
303
304        let mut custom = ContentStyle::new();
305        custom.background_color = Some(Color::Black);
306        custom.foreground_color = Some(Color::Yellow);
307
308        // render all replacements in a vertical list
309
310        stdout.queue(cursor::SavePosition)?;
311        let _ = stdout.queue(cursor::MoveDown(1))?;
312
313        let active_idx = state.pick_idx;
314
315        let custom_content = if state.custom_replacement.is_empty() {
316            "..."
317        } else {
318            state.custom_replacement.as_str()
319        };
320
321        std::iter::once((&custom, custom_content))
322            .chain(std::iter::once((
323                &others,
324                state.backticked_original.as_str(),
325            )))
326            .chain(
327                state
328                    .suggestion
329                    .replacements
330                    .iter()
331                    .map(|s| (&others, s.as_str())),
332            )
333            .enumerate()
334            .map(|(idx, (style, content))| {
335                (idx, PrintStyledContent(StyledContent::new(*style, content)))
336            })
337            .try_fold(&mut stdout, |cmd, (idx, mut item)| {
338                let cmd = cmd
339                    .queue(cursor::MoveUp(1))?
340                    .queue(terminal::Clear(terminal::ClearType::CurrentLine))?;
341
342                if idx == active_idx {
343                    *item.0.style_mut() = highlight;
344                    if idx == 0 {
345                        cmd.queue(crossterm::cursor::Show)?;
346                    } else {
347                        cmd.queue(crossterm::cursor::Hide)?;
348                    }
349                    cmd.queue(cursor::MoveToColumn(2))?
350                        .queue(PrintStyledContent(StyledContent::new(tick, 'ยป')))?
351                        .queue(cursor::MoveToColumn(4))?
352                } else {
353                    cmd.queue(cursor::MoveToColumn(4))?
354                }
355                .queue(item)
356            })?;
357
358        stdout.queue(cursor::RestorePosition)?.flush()?;
359
360        Ok(())
361    }
362
363    /// Wait for user input and process it into a `UserSelection` enum.
364    fn user_input(
365        &self,
366        state: &mut State,
367        running_idx: usize,
368        total: usize,
369    ) -> Result<UserSelection> {
370        let skip = {
371            let _guard = ScopedRaw::new();
372
373            let mut boring = ContentStyle::new();
374            boring.foreground_color = Some(Color::Blue);
375            boring.attributes = Attribute::Bold.into();
376
377            let question = format!(
378                "({nth}/{of_n}) Apply this suggestion [y,n,q,a,d,j,e,?]?",
379                nth = running_idx + 1,
380                of_n = total
381            );
382
383            // a new suggestion, so prepare for the number of items that are visible
384            // and also overwrite the last lines of the regular print which would
385            // already contain the suggestions
386            // TODO deal with error conversion
387
388            // erase this many lines of the regular print
389            #[allow(clippy::items_after_statements)]
390            const ERASE: u16 = 4;
391            // lines used by the question
392            const QUESTION: u16 = 3;
393            let extra_rows_to_flush =
394                (state.n_items.saturating_sub((ERASE - QUESTION) as usize)) as u16;
395            stdout()
396                .queue(cursor::Hide)?
397                .queue(cursor::MoveUp(ERASE))? // erase the 5 last lines of suggestion print
398                .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?
399                .queue(cursor::MoveDown(1))? // add a space between the question and the error
400                .queue(PrintStyledContent(StyledContent::new(boring, question)))?
401                .queue(terminal::ScrollUp(extra_rows_to_flush))?
402                .queue(cursor::MoveToColumn(0))?
403                .queue(cursor::MoveDown(extra_rows_to_flush))?;
404            ERASE - QUESTION
405        };
406
407        loop {
408            let mut _guard = ScopedRaw::new();
409
410            self.print_replacements_list(state)?;
411
412            if state.is_custom_entry() {
413                stdout().queue(cursor::SavePosition)?;
414                stdout()
415                    .queue(cursor::Show)?
416                    .queue(cursor::MoveToPreviousLine(1 - skip))?
417                    .queue(cursor::MoveToColumn(4 + state.cursor_offset))?;
418                stdout().flush()?;
419            }
420
421            let event = match crossterm::event::read()
422                .wrap_err_with(|| eyre!("Something unexpected happened on the CLI"))?
423            {
424                Event::Key(event) => event,
425                Event::Resize(..) => {
426                    drop(_guard);
427                    continue;
428                }
429                sth => {
430                    log::trace!("read() something other than a key: {sth:?}");
431                    break;
432                }
433            };
434
435            if state.is_custom_entry() {
436                drop(_guard);
437                log::info!("Custom entry mode");
438                _guard = ScopedRaw::new();
439
440                let pick = self.enter_custom_replacement(state, event)?;
441
442                stdout()
443                    .queue(cursor::Hide)?
444                    .queue(cursor::RestorePosition)?;
445
446                match pick {
447                    UserSelection::Nop => continue,
448                    other => return Ok(other),
449                }
450            }
451
452            drop(_guard);
453            // print normally again
454            log::trace!("registered event: {event:?}");
455
456            let KeyEvent {
457                code, modifiers, ..
458            } = event;
459
460            match code {
461                KeyCode::Up => state.select_next(),
462                KeyCode::Down => state.select_previous(),
463                KeyCode::Enter | KeyCode::Char('y') => {
464                    let bandaid = state.to_bandaid();
465                    // TODO handle interactive intput for those where there are no suggestions
466                    return Ok(UserSelection::Replacement(bandaid));
467                }
468                KeyCode::Char('n') => return Ok(UserSelection::Skip),
469                KeyCode::Char('j') => return Ok(UserSelection::Previous),
470                KeyCode::Char('q') | KeyCode::Esc => return Ok(UserSelection::Quit),
471                KeyCode::Char('c') if modifiers == KeyModifiers::CONTROL => {
472                    return Ok(UserSelection::Abort)
473                }
474                KeyCode::Char('d') => return Ok(UserSelection::SkipFile),
475                KeyCode::Char('e') => {
476                    // jump to the user input entry
477                    state.select_custom();
478                }
479                KeyCode::Char('?') => return Ok(UserSelection::Help),
480                x => {
481                    log::trace!("Unexpected input {x:?}");
482                }
483            }
484        }
485        unreachable!("Unexpected return when dealing with user input")
486    }
487
488    pub(super) fn select_interactive(
489        origin: ContentOrigin,
490        suggestions: Vec<Suggestion<'_>>,
491    ) -> Result<(Self, UserSelection)> {
492        let count = suggestions.len();
493        let mut picked = UserPicked::default();
494
495        let mut suggestions_it = suggestions.iter().enumerate();
496        let start = suggestions_it.clone();
497
498        // TODO make use of it
499        let direction = Direction::Forward;
500        'outer: loop {
501            let opt_next = match direction {
502                Direction::Forward => suggestions_it.next(),
503                // FIXME TODO this is just plain wrong
504                Direction::Backward => suggestions_it.next_back(),
505            };
506
507            log::trace!("next() ---> {opt_next:?}");
508
509            let (idx, suggestion) = match opt_next {
510                Some(x) => x,
511                None => match direction {
512                    Direction::Forward => {
513                        log::trace!("completed file, continue to next");
514                        break; // we completed this file, move on to the next
515                    }
516                    Direction::Backward => {
517                        log::trace!("went back, now back at the beginning");
518                        suggestions_it = start.clone();
519                        continue;
520                    } // go to the start
521                },
522            };
523            if suggestion.replacements.is_empty() {
524                log::trace!("BUG: Suggestion did not contain a replacement, skip");
525                continue;
526            }
527            println!("{suggestion}");
528
529            let mut state = State::from(suggestion);
530
531            'inner: loop {
532                match picked.user_input(&mut state, idx, count)? {
533                    usel @ (UserSelection::Abort | UserSelection::Quit) => {
534                        let _ = ScopedRaw::restore_terminal();
535                        return Ok((picked, usel));
536                    }
537                    UserSelection::SkipFile => break 'outer,
538                    UserSelection::Previous => {
539                        log::warn!("Requires a iterator which works bidrectionally");
540                        continue 'inner;
541                    }
542                    UserSelection::Help => {
543                        println!("{HELP}");
544                        continue 'inner;
545                    }
546                    UserSelection::Replacement(bandaid) => {
547                        picked.add_bandaid(&origin, bandaid);
548                    }
549                    UserSelection::Nop | UserSelection::Skip => {}
550                };
551                break 'inner;
552            }
553        }
554        Ok((picked, UserSelection::Nop))
555    }
556}