thag_rs/
repl.rs

1#![allow(clippy::uninlined_format_args)]
2use crate::{
3    builder::process_expr,
4    code_utils::{self, clean_up, display_dir_contents, extract_ast_expr},
5    key, lazy_static_var,
6    manifest::extract,
7    tui_editor::{
8        script_key_handler, tui_edit, EditData, Entry, History, KeyAction, KeyDisplay,
9        ManagedTerminal, RataStyle,
10    },
11    BuildState, Cli, ColorSupport, CrosstermEventReader, EventReader, KeyCombination,
12    KeyDisplayLine, ProcFlags, ThagError, ThagResult,
13};
14use clap::{CommandFactory, Parser};
15// use crossterm::style::types::color::Color as ReedlineColor;
16use edit::edit_file;
17use nu_ansi_term::{Color as NuColor, Style as NuStyle};
18use ratatui::crossterm::event::{KeyEvent, KeyEventKind};
19use reedline::{
20    default_emacs_keybindings, Color as ReedLineColor, ColumnarMenu, DefaultCompleter,
21    DefaultHinter, DefaultValidator, EditCommand, Emacs, ExampleHighlighter, FileBackedHistory,
22    HistoryItem, KeyCode, KeyModifiers, Keybindings, MenuBuilder, Prompt, PromptEditMode,
23    PromptHistorySearch, PromptHistorySearchStatus, Reedline, ReedlineEvent, ReedlineMenu, Signal,
24};
25use regex::Regex;
26use std::{
27    borrow::Cow,
28    collections::HashMap,
29    fmt::{Debug, Write as _},
30    fs::{self, read_to_string, OpenOptions},
31    io::{BufWriter, Write},
32    path::{Path, PathBuf},
33    str::FromStr,
34    time::Instant,
35};
36use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr};
37use thag_profiler::profiled;
38use thag_styling::{
39    display_terminal_attributes, display_theme_details, display_theme_roles, re, sprtln, vprtln,
40    Role, Style, TermAttributes, ThemedStyle, V,
41};
42use tui_textarea::{Input, TextArea};
43
44/// The filename for the REPL history file.
45pub const HISTORY_FILE: &str = "thag_repl_hist.txt";
46
47/// The default multiline indicator string used in the REPL prompt.
48pub static DEFAULT_MULTILINE_INDICATOR: &str = "";
49
50const EVENT_DESCS: &[[&str; 2]; 33] = &[
51    [
52        "HistoryHintComplete",
53        "Complete history hint (default in full)",
54    ],
55    [
56        "HistoryHintWordComplete",
57        "Complete a single token/word of the history hint",
58    ],
59    ["CtrlD", "Handle EndOfLine event"],
60    ["CtrlC", "Handle SIGTERM key input"],
61    [
62        "ClearScreen",
63        "Clears the screen and sets prompt to first line",
64    ],
65    [
66        "ClearScrollback",
67        "Clears the screen and the scrollback buffer, sets the prompt back to the first line",
68    ],
69    ["Enter", "Handle enter event"],
70    ["Submit", "Handle unconditional submit event"],
71    [
72        "SubmitOrNewline",
73        "Submit at the end of the *complete* text, otherwise newline",
74    ],
75    ["Esc", "Esc event"],
76    ["Mouse", "Mouse"],
77    ["Resize(u16, u16)", "trigger terminal resize"],
78    [
79        "Edit(Vec<EditCommand>)",
80        "Run §these commands in the editor",
81    ],
82    ["Repaint", "Trigger full repaint"],
83    [
84        "PreviousHistory",
85        "Navigate to the previous historic buffer",
86    ],
87    [
88        "Up",
89        "Move up to the previous line, if multiline, or up into the historic buffers",
90    ],
91    [
92        "Down",
93        "Move down to the next line, if multiline, or down through the historic buffers",
94    ],
95    [
96        "Right",
97        "Move right to the next column, completion entry, or complete hint",
98    ],
99    ["Left", "Move left to the next column, or completion entry"],
100    ["NextHistory", "Navigate to the next historic buffer"],
101    ["SearchHistory", "Search the history for a string"],
102    ["Multiple(Vec<ReedlineEvent>)", "Multiple chained (Vi)"],
103    ["UntilFound(Vec<ReedlineEvent>)", "Test"],
104    [
105        "Menu(String)",
106        "Trigger a menu event. It activates a menu with the event name",
107    ],
108    ["MenuNext", "Next element in the menu"],
109    ["MenuPrevious", "Previous element in the menu"],
110    ["MenuUp", "Moves up in the menu"],
111    ["MenuDown", "Moves down in the menu"],
112    ["MenuLeft", "Moves left in the menu"],
113    ["MenuRight", "Moves right in the menu"],
114    ["MenuPageNext", "Move to the next history page"],
115    ["MenuPagePrevious", "Move to the previous history page"],
116    ["OpenEditor", "Open text editor"],
117];
118
119const CMD_DESCS: &[[&str; 2]; 59] = &[
120    ["MoveToStart", "Move to the start of the buffer"],
121    ["MoveToLineStart", "Move to the start of the current line"],
122    ["MoveToEnd", "Move to the end of the buffer"],
123    ["MoveToLineEnd", "Move to the end of the current line"],
124    ["MoveLeft", "Move one character to the left"],
125    ["MoveRight", "Move one character to the right"],
126    ["MoveWordLeft", "Move one word to the left"],
127    ["MoveBigWordLeft", "Move one WORD to the left"],
128    ["MoveWordRight", "Move one word to the right"],
129    ["MoveWordRightStart", "Move one word to the right, stop at start of word"],
130    ["MoveBigWordRightStart", "Move one WORD to the right, stop at start of WORD"],
131    ["MoveWordRightEnd", "Move one word to the right, stop at end of word"],
132    ["MoveBigWordRightEnd", "Move one WORD to the right, stop at end of WORD"],
133    ["MoveToPosition", "Move to position"],
134    ["InsertChar", "Insert a character at the current insertion point"],
135    ["InsertString", "Insert a string at the current insertion point"],
136    ["InsertNewline", "Insert the system specific new line character"],
137    ["ReplaceChars", "Replace characters with string"],
138    ["Backspace", "Backspace delete from the current insertion point"],
139    ["Delete", "Delete in-place from the current insertion point"],
140    ["CutChar", "Cut the grapheme right from the current insertion point"],
141    ["BackspaceWord", "Backspace delete a word from the current insertion point"],
142    ["DeleteWord", "Delete in-place a word from the current insertion point"],
143    ["Clear", "Clear the current buffer"],
144    ["ClearToLineEnd", "Clear to the end of the current line"],
145    ["Complete", "Insert completion: entire completion if there is only one possibility, or else up to shared prefix."],
146    ["CutCurrentLine", "Cut the current line"],
147    ["CutFromStart", "Cut from the start of the buffer to the insertion point"],
148    ["CutFromLineStart", "Cut from the start of the current line to the insertion point"],
149    ["CutToEnd", "Cut from the insertion point to the end of the buffer"],
150    ["CutToLineEnd", "Cut from the insertion point to the end of the current line"],
151    ["CutWordLeft", "Cut the word left of the insertion point"],
152    ["CutBigWordLeft", "Cut the WORD left of the insertion point"],
153    ["CutWordRight", "Cut the word right of the insertion point"],
154    ["CutBigWordRight", "Cut the WORD right of the insertion point"],
155    ["CutWordRightToNext", "Cut the word right of the insertion point and any following space"],
156    ["CutBigWordRightToNext", "Cut the WORD right of the insertion point and any following space"],
157    ["PasteCutBufferBefore", "Paste the cut buffer in front of the insertion point (Emacs, vi P)"],
158    ["PasteCutBufferAfter", "Paste the cut buffer in front of the insertion point (vi p)"],
159    ["UppercaseWord", "Upper case the current word"],
160    ["LowercaseWord", "Lower case the current word"],
161    ["CapitalizeChar", "Capitalize the current character"],
162    ["SwitchcaseChar", "Switch the case of the current character"],
163    ["SwapWords", "Swap the current word with the word to the right"],
164    ["SwapGraphemes", "Swap the current grapheme/character with the one to the right"],
165    ["Undo", "Undo the previous edit command"],
166    ["Redo", "Redo an edit command from the undo history"],
167    ["CutRightUntil", "CutUntil right until char"],
168    ["CutRightBefore", "CutUntil right before char"],
169    ["MoveRightUntil", "MoveUntil right until char"],
170    ["MoveRightBefore", "MoveUntil right before char"],
171    ["CutLeftUntil", "CutUntil left until char"],
172    ["CutLeftBefore", "CutUntil left before char"],
173    ["MoveLeftUntil", "MoveUntil left until char"],
174    ["MoveLeftBefore", "MoveUntil left before char"],
175    ["SelectAll", "Select whole input buffer"],
176    ["CutSelection", "Cut selection to local buffer"],
177    ["CopySelection", "Copy selection to local buffer"],
178    ["Paste", "Paste content from local buffer at the current cursor position"],
179];
180
181/// REPL mode lets you type or paste a Rust expression to be evaluated.
182///
183/// Enter the expression to be evaluated.
184///
185/// Expressions between matching braces, brackets, parens or quotes may span multiple lines.
186///
187/// Expressions starting with "// ", "/// ", or  "\[Cc\]omment " will be ignored as comments.
188///
189/// If valid, the expression will be converted into a Rust program, and built and run using Cargo.
190///
191/// Dependencies will be inferred from imports if possible using a Cargo search, but the overhead
192/// of doing so can be avoided by placing them in Cargo.toml format at the top of the expression in a
193/// comment block of the form
194/// ``` rustdoc
195/// /*[toml]
196/// [dependencies]
197/// ...
198/// */
199/// ```
200/// From here they will be extracted to a dedicated Cargo.toml file.
201///
202/// In this case the whole expression must be enclosed in curly braces to include the TOML in the expression.
203///
204/// At any stage before exiting the REPL, or at least as long as your TMPDIR is not cleared, you can
205/// go back and edit your expression or its generated Cargo.toml file and copy or save them from the
206/// editor or directly from their temporary disk locations.
207///
208/// The tab key will show command selections and complete partial matching selections.
209#[derive(Debug, Parser, EnumIter, EnumString, IntoStaticStr)]
210#[command(
211    name = "",
212    disable_help_flag = true,
213    disable_help_subcommand = true,
214    verbatim_doc_comment
215)] // Disable automatic help subcommand and flag
216#[strum(serialize_all = "snake_case")]
217#[allow(clippy::module_name_repetitions)]
218pub enum ReplCommand {
219    /// Show the REPL banner
220    Banner,
221    /// Promote the Rust expression to the TUI REPL, which can handle any script.
222    /// This is a one-way process, but the original expression will be saved in history.
223    Tui,
224    /// Edit the Rust expression in the configured or default editor.
225    /// Edit+run is an alternative to prompt-line evaluation or TUI for longer snippets and programs.
226    Edit,
227    /// Edit the generated Cargo.toml
228    Toml,
229    /// Attempt to build and run the Rust expression
230    Run,
231    /// Delete all temporary files for the current evaluation (see `list` command)
232    Delete,
233    /// List temporary files for this the current evaluation
234    List,
235    /// Edit history
236    History,
237    /// Show help information
238    Help,
239    /// Show key bindings
240    Keys,
241    /// Show theme and terminal attributes (change via `thag -C` and rerun)
242    Theme,
243    /// Exit the REPL
244    Quit,
245}
246
247impl ReplCommand {
248    #[profiled]
249    fn print_help() {
250        let mut command = Self::command();
251        // let mut buf = Vec::new();
252        // command.write_help(&mut buf).unwrap();
253        // let help_message = String::from_utf8(buf).unwrap();
254        println!("{}", command.render_long_help());
255    }
256}
257
258/// A struct to implement the Prompt trait.
259#[allow(clippy::module_name_repetitions)]
260pub struct ReplPrompt(pub &'static str);
261impl Prompt for ReplPrompt {
262    #[profiled]
263    fn render_prompt_left(&self) -> Cow<'_, str> {
264        Cow::Owned(self.0.to_string())
265    }
266
267    #[profiled]
268    fn render_prompt_right(&self) -> Cow<'_, str> {
269        Cow::Owned(String::new())
270    }
271
272    #[profiled]
273    fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> {
274        Cow::Owned("> ".to_string())
275    }
276
277    #[profiled]
278    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
279        Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR)
280    }
281
282    #[profiled]
283    fn render_prompt_history_search_indicator(
284        &self,
285        history_search: PromptHistorySearch,
286    ) -> Cow<'_, str> {
287        let prefix = match history_search.status {
288            PromptHistorySearchStatus::Passing => "",
289            PromptHistorySearchStatus::Failing => "failing ",
290        };
291
292        Cow::Owned(format!(
293            "({}reverse-search: {}) ",
294            prefix, history_search.term
295        ))
296    }
297
298    #[profiled]
299    fn get_prompt_color(&self) -> reedline::Color {
300        lazy_static_var!(ReedLineColor, deref, ReedLineColor::themed(Role::Success))
301    }
302}
303
304#[profiled]
305fn get_heading_style() -> &'static Style {
306    lazy_static_var!(Style, Style::for_role(Role::HD1))
307}
308
309#[profiled]
310fn get_subhead_style() -> &'static Style {
311    lazy_static_var!(Style, Style::for_role(Role::HD2))
312}
313
314/// Add menu keybindings to the provided keybindings configuration.
315#[profiled]
316pub fn add_menu_keybindings(keybindings: &mut Keybindings) {
317    keybindings.add_binding(
318        KeyModifiers::NONE,
319        KeyCode::Tab,
320        ReedlineEvent::UntilFound(vec![
321            ReedlineEvent::Menu("completion_menu".to_string()),
322            ReedlineEvent::MenuNext,
323        ]),
324    );
325    keybindings.add_binding(
326        KeyModifiers::ALT,
327        KeyCode::Enter,
328        ReedlineEvent::Edit(vec![EditCommand::InsertNewline]),
329    );
330    keybindings.add_binding(
331        KeyModifiers::NONE,
332        KeyCode::F(7),
333        ReedlineEvent::PreviousHistory,
334    );
335    keybindings.add_binding(
336        KeyModifiers::NONE,
337        KeyCode::F(8),
338        ReedlineEvent::NextHistory,
339    );
340}
341
342/// Run the REPL.
343/// # Errors
344/// Will return `Err` if there is any error in running the REPL.
345/// # Panics
346/// Will panic if there is a problem configuring the `reedline` history file.
347#[allow(clippy::module_name_repetitions)]
348#[allow(clippy::too_many_lines)]
349pub fn run_repl(
350    args: &Cli,
351    proc_flags: &ProcFlags,
352    build_state: &mut BuildState,
353    start: Instant,
354) -> ThagResult<()> {
355    #[allow(unused_variables)]
356    let history_path = build_state.cargo_home.join(HISTORY_FILE);
357    let hist_staging_path: PathBuf = build_state.cargo_home.join("hist_staging.txt");
358    let hist_backup_path: PathBuf = build_state.cargo_home.join("hist_backup.txt");
359    let history = Box::new(FileBackedHistory::with_file(40, history_path.clone())?);
360
361    let cmd_vec = ReplCommand::iter()
362        .map(<ReplCommand as Into<&'static str>>::into)
363        .map(String::from)
364        .collect::<Vec<String>>();
365
366    let completer = Box::new(DefaultCompleter::new_with_wordlen(cmd_vec.clone(), 2));
367
368    // Use the interactive menu to select options from the completer
369    let columnar_menu = ColumnarMenu::default()
370        .with_name("completion_menu")
371        .with_columns(4)
372        .with_column_width(None)
373        .with_column_padding(2);
374
375    let completion_menu = Box::new(columnar_menu);
376
377    let mut keybindings = default_emacs_keybindings();
378    add_menu_keybindings(&mut keybindings);
379    // println!("{:#?}", keybindings.get_keybindings());
380
381    let edit_mode = Box::new(Emacs::new(keybindings.clone()));
382    let mut line_editor_builder = Reedline::create()
383        .with_validator(Box::new(DefaultValidator))
384        .with_history(history)
385        .with_history_exclusion_prefix(Some("q".into()))
386        .with_completer(completer)
387        .with_menu(ReedlineMenu::EngineCompleter(completion_menu))
388        .with_edit_mode(edit_mode)
389        .with_ansi_colors(TermAttributes::get_or_init().color_support != ColorSupport::None);
390
391    let term_attrs = TermAttributes::get_or_init();
392
393    // Only add highlighting if color support is available
394    if matches!(
395        term_attrs.color_support,
396        ColorSupport::None | ColorSupport::Undetermined
397    ) {
398        // Add default hinter without styling when no color support
399        line_editor_builder = line_editor_builder.with_hinter(Box::new(DefaultHinter::default()));
400    } else {
401        let mut highlighter = Box::new(ExampleHighlighter::new(cmd_vec.clone()));
402        let nu_match = NuColor::themed(Role::Code);
403        let nu_notmatch = NuColor::themed(Role::Info);
404        let nu_neutral = NuColor::themed(Role::Emphasis);
405        highlighter.change_colors(nu_match, nu_notmatch, nu_neutral);
406
407        // Add highlighter to builder
408        line_editor_builder = line_editor_builder.with_highlighter(highlighter);
409
410        // Add styled hinter
411        line_editor_builder = line_editor_builder.with_hinter(Box::new(
412            DefaultHinter::default().with_style(NuStyle::themed(Role::Hint).italic()),
413        ));
414    }
415
416    let mut line_editor = line_editor_builder;
417
418    let bindings = keybindings.get_keybindings();
419    let reedline_events = bindings.values().cloned().collect::<Vec<ReedlineEvent>>();
420    let max_cmd_len = get_max_cmd_len(&reedline_events);
421
422    let prompt = ReplPrompt("repl");
423    let cmd_list = &cmd_vec.join(", ");
424    disp_repl_banner(cmd_list);
425
426    // Collect and format key bindings while user is taking in the display banner
427    // NB: Can't extract this to a method either, because reedline does not expose KeyCombination.
428    let named_reedline_events = bindings
429        .iter()
430        .map(|(key_combination, reedline_event)| {
431            let key_modifiers = key_combination.modifier;
432            let key_code = key_combination.key_code;
433            let modifier = format_key_modifier(key_modifiers);
434            let key = format_key_code(key_code);
435            let key_desc = format!("{modifier}{key}");
436            (key_desc, reedline_event)
437        })
438        // .cloned()
439        .collect::<Vec<(String, &ReedlineEvent)>>();
440    let formatted_bindings = format_bindings(&named_reedline_events, max_cmd_len);
441
442    // Determine the length of the longest key description for padding
443    let max_key_len = lazy_static_var!(usize, deref, get_max_key_len(formatted_bindings));
444    // eprintln!("max_key_len={max_key_len}");
445
446    loop {
447        let sig = line_editor.read_line(&prompt)?;
448        let input: &str = match sig {
449            Signal::Success(ref buffer) => buffer,
450            Signal::CtrlD | Signal::CtrlC => {
451                break;
452            }
453        };
454
455        // Process user input (line)
456
457        let rs_source = input.trim();
458        if rs_source.is_empty() {
459            continue;
460        }
461
462        let (first_word, rest) = parse_line(rs_source);
463
464        if first_word == "#"
465            || first_word == "//"
466            || first_word == "///"
467            || first_word.to_lowercase() == "comment"
468        {
469            // sprtln!(Role::HD3, "{rs_source}");
470            continue;
471        }
472        // vprtln!(V::VV, "first_word={first_word}, rest={rest:#?}");
473        let maybe_cmd = if rest.is_empty() {
474            let mut matches = 0;
475            let mut cmd = String::new();
476            for key in &cmd_vec {
477                if key.starts_with(&first_word) {
478                    matches += 1;
479                    // Selects last match
480                    if matches == 1 {
481                        cmd = key.to_string();
482                    }
483                    // eprintln!("key={key}, split[0]={}", split[0]);
484                }
485            }
486            if matches == 1 {
487                Some(cmd)
488            } else {
489                // println!("No single matching key found");
490                None
491            }
492        } else {
493            None
494        };
495
496        if let Some(cmd) = maybe_cmd {
497            if let Ok(repl_command) = ReplCommand::from_str(&cmd) {
498                match repl_command {
499                    ReplCommand::Banner => disp_repl_banner(cmd_list),
500                    ReplCommand::Help => {
501                        ReplCommand::print_help();
502                    }
503                    ReplCommand::Quit => {
504                        break;
505                    }
506                    ReplCommand::Tui => {
507                        if TermAttributes::get_or_init().color_support == ColorSupport::None {
508                            println!("Sorry, TUI features require terminal color support");
509                            continue;
510                        }
511                        let source_path = &build_state.source_path;
512                        let save_path: PathBuf = build_state.cargo_home.join("repl_tui_save.rs");
513                        // let backup_path: PathBuf =
514                        //     &build_state.cargo_home.join("repl_tui_backup.rs");
515
516                        let rs_source = read_to_string(source_path)?;
517                        tui(
518                            rs_source.as_str(),
519                            &save_path,
520                            build_state,
521                            args,
522                            proc_flags,
523                        )?;
524                    }
525                    ReplCommand::Edit => {
526                        edit(&build_state.source_path)?;
527                    }
528                    ReplCommand::Toml => {
529                        toml(build_state)?;
530                    }
531                    ReplCommand::Run => {
532                        let rs_source = code_utils::read_file_contents(&build_state.source_path)?;
533                        process_source(&rs_source, build_state, args, proc_flags, start)?;
534                    }
535                    ReplCommand::Delete => {
536                        delete(build_state)?;
537                    }
538                    ReplCommand::List => {
539                        list(build_state)?;
540                    }
541                    ReplCommand::History => {
542                        if TermAttributes::get_or_init().color_support == ColorSupport::None {
543                            println!("Sorry, TUI features require terminal color support");
544                            continue;
545                        }
546                        review_history(
547                            &mut line_editor,
548                            &history_path,
549                            &hist_backup_path,
550                            &hist_staging_path,
551                        )?;
552                    }
553                    ReplCommand::Keys => {
554                        show_key_bindings(formatted_bindings, max_key_len);
555                    }
556                    ReplCommand::Theme => {
557                        let term_attrs = TermAttributes::get_or_init();
558                        let theme = &term_attrs.theme;
559
560                        display_theme_roles(theme);
561                        display_theme_details(theme);
562                        display_terminal_attributes(theme);
563                    }
564                }
565                continue;
566            }
567        }
568
569        process_source(rs_source, build_state, args, proc_flags, start)?;
570    }
571    Ok(())
572}
573
574/// Process a source string through to completion according to the arguments passed in.
575///
576/// # Errors
577///
578/// This function will bubble up any error encountered in processing.
579#[profiled]
580pub fn process_source(
581    rs_source: &str,
582    build_state: &mut BuildState,
583    args: &Cli,
584    proc_flags: &ProcFlags,
585    start: Instant,
586) -> ThagResult<()> {
587    let rs_manifest = extract(rs_source, Instant::now())?;
588    build_state.rs_manifest = Some(rs_manifest);
589    let maybe_ast = extract_ast_expr(rs_source);
590    if let Ok(expr_ast) = maybe_ast {
591        build_state.ast = Some(crate::Ast::Expr(expr_ast));
592        process_expr(build_state, rs_source, args, proc_flags, &start)?;
593    } else {
594        sprtln!(Role::ERR, "Error parsing code: {maybe_ast:#?}");
595    }
596    Ok(())
597}
598
599#[profiled]
600fn tui(
601    initial_content: &str,
602    save_path: &Path,
603    build_state: &mut BuildState,
604    args: &Cli,
605    proc_flags: &ProcFlags,
606) -> ThagResult<()> {
607    let cargo_home = std::env::var("CARGO_HOME").unwrap_or_else(|_| ".".into());
608    let history_path = PathBuf::from(cargo_home).join("rs_stdin_history.json");
609    let mut history = History::load_from_file(&history_path);
610    let initial_content = if initial_content.trim().is_empty() {
611        history.get_last().map_or_else(String::new, Entry::contents)
612    } else {
613        history.add_entry(initial_content);
614        history.save_to_file(&history_path)?;
615        initial_content.to_string()
616    };
617
618    let event_reader = CrosstermEventReader;
619    let mut edit_data = EditData {
620        return_text: true,
621        initial_content: &initial_content,
622        save_path: Some(save_path.to_path_buf()),
623        history_path: Some(&history_path),
624        history: Some(history),
625    };
626    let add_keys = [
627        KeyDisplayLine::new(371, "Ctrl+Alt+s", "Save a copy"),
628        KeyDisplayLine::new(372, "F3", "Discard saved and unsaved changes, and exit"),
629        // KeyDisplayLine::new(373, "F4", "Clear text buffer (Ctrl+y or Ctrl+u to restore)"),
630    ];
631
632    let display = KeyDisplay {
633        title: "Edit TUI script.  ^d: submit  ^q: quit  ^s: save  F3: abandon  ^l: keys  ^t: toggle highlighting",
634        title_style: RataStyle::themed(Role::HD2),
635        remove_keys: &[""; 0],
636        add_keys: &add_keys,
637    };
638    let (key_action, maybe_text) = tui_edit(
639        &event_reader,
640        &mut edit_data,
641        &display,
642        |key_event,
643         maybe_term,
644         /*maybe_save_file,*/ textarea,
645         edit_data,
646         popup,
647         saved,
648         status_message| {
649            script_key_handler(
650                key_event,
651                maybe_term, // maybe_save_file,
652                textarea,
653                edit_data,
654                popup,
655                saved,
656                status_message,
657            )
658        },
659    )?;
660    let _ = match key_action {
661        // KeyAction::Quit(_saved) => false,
662        KeyAction::Save
663        | KeyAction::ShowHelp
664        | KeyAction::ToggleHighlight
665        | KeyAction::TogglePopup => {
666            return Err(
667                format!("Logic error: {key_action:?} should not return from tui_edit").into(),
668            )
669        }
670        // KeyAction::SaveAndExit => false,
671        KeyAction::Submit => {
672            return maybe_text.map_or(Err(ThagError::Cancelled), |v| {
673                let rs_source = v.join("\n");
674                process_source(&rs_source, build_state, args, proc_flags, Instant::now())
675            });
676        }
677        _ => false,
678    };
679    Ok(())
680}
681
682#[profiled]
683fn review_history(
684    line_editor: &mut Reedline,
685    history_path: &PathBuf,
686    backup_path: &PathBuf,
687    staging_path: &PathBuf,
688) -> ThagResult<()> {
689    let event_reader = CrosstermEventReader;
690    line_editor.sync_history()?;
691    fs::copy(history_path, backup_path)?;
692    let history_string = read_to_string(history_path)?;
693    let confirm = edit_history(&history_string, staging_path, &event_reader)?;
694    if confirm {
695        let history_mut = line_editor.history_mut();
696        let saved_history = fs::read_to_string(staging_path)?;
697        eprintln!("staging_path={}", staging_path.display());
698        eprintln!("saved_history={saved_history}");
699        history_mut.clear()?;
700        for line in saved_history.lines() {
701            let entry = decode(line);
702            // eprintln!("saving entry={entry}");
703            let _ = history_mut.save(HistoryItem::from_command_line(entry))?;
704        }
705        history_mut.sync()?;
706    }
707    Ok(())
708}
709
710/// Convert the `reedline` file-backed history newline sequence <\n> into the '\n' (0xa) character for which it stands.
711#[must_use]
712#[allow(clippy::missing_panics_doc)]
713#[profiled]
714pub fn decode(input: &str) -> String {
715    let re = re!(r"(<\\n>)");
716    let lf = std::str::from_utf8(&[10_u8]).unwrap();
717    re.replace_all(input, lf).to_string()
718}
719
720/// Edit the history.
721///
722/// # Errors
723///
724/// This function will bubble up any i/o, `ratatui` or `crossterm` errors encountered.
725#[profiled]
726pub fn edit_history<R: EventReader + Debug>(
727    initial_content: &str,
728    staging_path: &Path,
729    event_reader: &R,
730) -> ThagResult<bool> {
731    let mut edit_data = EditData {
732        return_text: false,
733        initial_content,
734        save_path: Some(staging_path.to_path_buf()),
735        history_path: None,
736        history: None::<History>,
737    };
738    let binding = [
739        KeyDisplayLine::new(372, "F3", "Discard saved and unsaved changes, and exit"),
740        // KeyDisplayLine::new(373, "F4", "Clear text buffer (Ctrl+y or Ctrl+u to restore)"),
741    ];
742    let display = KeyDisplay {
743        title: "Enter / paste / edit REPL history.  ^d: save & exit  ^q: quit  ^s: save  F3: abandon  ^l: keys  ^t: toggle highlighting",
744        title_style: RataStyle::themed(Role::HD2),
745        remove_keys: &["F7", "F8"],
746        add_keys: &binding,
747    };
748    let (key_action, _maybe_text) = tui_edit(
749        event_reader,
750        &mut edit_data,
751        &display,
752        |key_event, maybe_term, textarea, edit_data, popup, saved, status_message| {
753            history_key_handler(
754                key_event,
755                maybe_term, // maybe_save_file,
756                textarea,
757                edit_data,
758                popup,
759                saved,
760                status_message,
761            )
762        },
763    )?;
764    Ok(match key_action {
765        KeyAction::Quit(saved) => saved,
766        KeyAction::Save
767        | KeyAction::ShowHelp
768        | KeyAction::ToggleHighlight
769        | KeyAction::TogglePopup => {
770            return Err(format!("Logic error: {key_action:?} should not return from tui_edit").into())
771        }
772        KeyAction::SaveAndSubmit => {
773            return Err(format!("Logic error: {key_action:?} should not be implemented in tui_edit or history_key_handler").into()
774            )
775        }
776        KeyAction::SaveAndExit => true,
777        _ => false,
778    })
779}
780
781/// Key handler function to be passed into `tui_edit` for editing REPL history.
782///
783/// # Errors
784///
785/// This function will bubble up any i/o, `ratatui` or `crossterm` errors encountered.
786#[profiled]
787pub fn history_key_handler(
788    key_event: KeyEvent,
789    _maybe_term: Option<&mut ManagedTerminal>,
790    // maybe_save_path: &mut Option<&mut PathBuf>,
791    textarea: &mut TextArea,
792    edit_data: &mut EditData,
793    popup: &mut bool,
794    saved: &mut bool,
795    status_message: &mut String,
796) -> ThagResult<KeyAction> {
797    // Make sure for Windows
798    if !matches!(key_event.kind, KeyEventKind::Press) {
799        return Ok(KeyAction::Continue);
800    }
801    let maybe_save_path = &edit_data.save_path;
802    let key_combination = KeyCombination::from(key_event); // Derive KeyCombination
803
804    match key_combination {
805        #[allow(clippy::unnested_or_patterns)]
806        key!(esc) | key!(ctrl - c) | key!(ctrl - q) => Ok(KeyAction::Quit(*saved)),
807        key!(ctrl - d) => {
808            // Save logic
809            save_file(maybe_save_path.as_ref(), textarea)?;
810            // println!("Saved");
811            Ok(KeyAction::SaveAndExit)
812        }
813        key!(ctrl - s) => {
814            // Save logic
815            let save_file = save_file(maybe_save_path.as_ref(), textarea)?;
816            // eprintln!("Saved {:?} to {save_file:?}", textarea.lines());
817            *saved = true;
818            status_message.clear();
819            let _ = write!(status_message, "Saved to {save_file}");
820            Ok(KeyAction::Save)
821        }
822        key!(ctrl - l) => {
823            // Toggle popup
824            *popup = !*popup;
825            Ok(KeyAction::TogglePopup)
826        }
827        key!(f3) => {
828            // Ask to revert
829            Ok(KeyAction::AbandonChanges)
830        }
831        _ => {
832            // Update the textarea with the input from the key event
833            textarea.input(Input::from(key_event)); // Input derived from Event
834            Ok(KeyAction::Continue)
835        }
836    }
837}
838
839#[profiled]
840fn save_file(maybe_save_path: Option<&PathBuf>, textarea: &TextArea<'_>) -> ThagResult<String> {
841    let staging_path = maybe_save_path.ok_or("Missing save_path")?;
842    let staging_file = OpenOptions::new()
843        .read(true)
844        .write(true)
845        .create(true)
846        .truncate(true)
847        .open(staging_path)?;
848    let mut f = BufWriter::new(&staging_file);
849    for line in textarea.lines() {
850        Write::write_all(&mut f, line.as_bytes())?;
851        Write::write_all(&mut f, b"\n")?;
852    }
853    Ok(staging_path.display().to_string())
854}
855
856/// Return the maximum length of the key descriptor for a set of styled and
857/// formatted key / description bindings to be displayed on screen.
858#[profiled]
859fn get_max_key_len(formatted_bindings: &[(String, String)]) -> usize {
860    let style = get_heading_style();
861    formatted_bindings
862        .iter()
863        .map(|(key_desc, _)| {
864            let key_desc = style.paint(key_desc);
865            key_desc.len()
866        })
867        .max()
868        .unwrap_or(0)
869}
870
871#[profiled]
872fn format_bindings(
873    named_reedline_events: &[(String, &ReedlineEvent)],
874    max_cmd_len: usize,
875) -> &'static Vec<(String, String)> {
876    lazy_static_var!(Vec<(String, String)>, {
877        let mut formatted_bindings = named_reedline_events
878            .iter()
879            .filter_map(|(key_desc, reedline_event)| {
880                if let ReedlineEvent::Edit(edit_cmds) = reedline_event {
881                    let cmd_desc = format_edit_commands(edit_cmds, max_cmd_len);
882                    Some((key_desc.clone(), cmd_desc))
883                } else {
884                    let event_name = format!("{reedline_event:?}");
885                    if event_name.starts_with("UntilFound") {
886                        None
887                    } else {
888                        let event_desc = format_non_edit_events(&event_name, max_cmd_len);
889                        Some((key_desc.clone(), event_desc))
890                    }
891                }
892            })
893            .collect::<Vec<(String, String)>>();
894        // Sort the formatted bindings alphabetically by key combination description
895        formatted_bindings.sort_by(|a, b| a.0.cmp(&b.0));
896        formatted_bindings
897    })
898}
899
900#[profiled]
901fn get_max_cmd_len(reedline_events: &[ReedlineEvent]) -> usize {
902    // Calculate max command len for padding
903    lazy_static_var!(usize, deref, {
904        // Determine the length of the longest command for padding
905        // NB: Can't extract this to a method because for some reason reedline does not expose KeyCombination.
906        let style = get_subhead_style();
907        let max_cmd_len = reedline_events
908            .iter()
909            .map(|reedline_event| {
910                if let ReedlineEvent::Edit(edit_cmds) = reedline_event {
911                    edit_cmds
912                        .iter()
913                        .map(|cmd| {
914                            let key_desc = style.paint(format!("{cmd:?}"));
915                            key_desc.len()
916                        })
917                        .max()
918                        .unwrap_or(0)
919                } else if !format!("{reedline_event}").starts_with("UntilFound") {
920                    let event_desc = style.paint(format!("{reedline_event:?}"));
921                    event_desc.len()
922                } else {
923                    0
924                }
925            })
926            .max()
927            .unwrap_or(0);
928        // Add 2 bytes of padding
929        max_cmd_len + 2
930    })
931}
932
933/// Display key bindings with their descriptions.
934#[profiled]
935pub fn show_key_bindings(formatted_bindings: &[(String, String)], max_key_len: usize) {
936    println!();
937    sprtln!(
938        Role::EMPH,
939        "Key bindings - subject to your terminal settings"
940    );
941
942    // Print the formatted and sorted key bindings
943    let style = get_heading_style();
944    for (key_desc, cmd_desc) in formatted_bindings {
945        let key_desc = style.paint(key_desc);
946        println!("{key_desc:<width$}    {cmd_desc}", width = max_key_len);
947    }
948    println!();
949}
950
951/// Helper function to convert `KeyModifiers` to string
952#[must_use]
953#[profiled]
954pub fn format_key_modifier(modifier: KeyModifiers) -> String {
955    let mut modifiers = Vec::new();
956    if modifier.contains(KeyModifiers::CONTROL) {
957        modifiers.push("CONTROL");
958    }
959    if modifier.contains(KeyModifiers::SHIFT) {
960        modifiers.push("SHIFT");
961    }
962    if modifier.contains(KeyModifiers::ALT) {
963        modifiers.push("ALT");
964    }
965    let mods_str = modifiers.join("+");
966    if modifiers.is_empty() {
967        mods_str
968    } else {
969        mods_str + "-"
970    }
971}
972
973/// Helper function to convert `KeyCode` to string
974#[must_use]
975#[profiled]
976pub fn format_key_code(key_code: KeyCode) -> String {
977    match key_code {
978        KeyCode::Backspace => "Backspace".to_string(),
979        KeyCode::Enter => "Enter".to_string(),
980        KeyCode::Left => "Left".to_string(),
981        KeyCode::Right => "Right".to_string(),
982        KeyCode::Up => "Up".to_string(),
983        KeyCode::Down => "Down".to_string(),
984        KeyCode::Home => "Home".to_string(),
985        KeyCode::End => "End".to_string(),
986        KeyCode::PageUp => "PageUp".to_string(),
987        KeyCode::PageDown => "PageDown".to_string(),
988        KeyCode::Tab => "Tab".to_string(),
989        KeyCode::BackTab => "BackTab".to_string(),
990        KeyCode::Delete => "Delete".to_string(),
991        KeyCode::Insert => "Insert".to_string(),
992        KeyCode::F(num) => format!("F{}", num),
993        KeyCode::Char(c) => format!("{}", c.to_uppercase()),
994        KeyCode::Null => "Null".to_string(),
995        KeyCode::Esc => "Esc".to_string(),
996        KeyCode::CapsLock => "CapsLock".to_string(),
997        KeyCode::ScrollLock => "ScrollLock".to_string(),
998        KeyCode::NumLock => "NumLock".to_string(),
999        KeyCode::PrintScreen => "PrintScreen".to_string(),
1000        KeyCode::Pause => "Pause".to_string(),
1001        KeyCode::Menu => "Menu".to_string(),
1002        KeyCode::KeypadBegin => "KeypadBegin".to_string(),
1003        KeyCode::Media(media) => format!("Media({:?})", media),
1004        KeyCode::Modifier(modifier) => format!("Modifier({:?})", modifier),
1005    }
1006}
1007
1008/// Helper function to format `ReedlineEvents` other than `Edit`, and their doc comments
1009/// # Panics
1010/// Will panic if it fails to split a `EVENT_DESC_MAP` entry, indicating a problem with the `EVENT_DESC_MAP`.
1011#[allow(clippy::too_many_lines)]
1012#[must_use]
1013#[profiled]
1014pub fn format_non_edit_events(event_name: &str, max_cmd_len: usize) -> String {
1015    let event_desc_map = lazy_static_var!(HashMap<&'static str, &'static str>, {
1016        EVENT_DESCS
1017            .iter()
1018            .map(|[k, d]| (*k, *d))
1019            .collect::<HashMap<&'static str, &'static str>>()
1020    });
1021
1022    let event_highlight = get_subhead_style().paint(event_name);
1023    let event_desc = format!(
1024        "{:<max_cmd_len$} {}",
1025        event_highlight,
1026        event_desc_map.get(event_name).unwrap_or(&"")
1027    );
1028    event_desc
1029}
1030
1031/// Helper function to format `EditCommand` and include its doc comments
1032/// # Panics
1033/// Will panic if it fails to split a `CMD_DESC_MAP` entry, indicating a problem with the `CMD_DESC_MAP`.
1034#[must_use]
1035#[profiled]
1036pub fn format_edit_commands(edit_cmds: &[EditCommand], max_cmd_len: usize) -> String {
1037    let cmd_desc_map: &HashMap<&str, &str> =
1038        lazy_static_var!(HashMap<&'static str, &'static str>, {
1039            CMD_DESCS
1040                .iter()
1041                .map(|[k, d]| (*k, *d))
1042                .collect::<HashMap<&'static str, &'static str>>()
1043        });
1044    let cmd_descriptions = edit_cmds
1045        .iter()
1046        .map(|cmd| format_cmd_desc(cmd, cmd_desc_map, max_cmd_len))
1047        .collect::<Vec<String>>();
1048
1049    cmd_descriptions.join(", ")
1050}
1051
1052#[allow(clippy::too_many_lines)]
1053#[profiled]
1054fn format_cmd_desc(
1055    cmd: &EditCommand,
1056    cmd_desc_map: &HashMap<&str, &str>,
1057    max_cmd_len: usize,
1058) -> String {
1059    let style = get_subhead_style();
1060
1061    let cmd_highlight = style.paint(format!("{cmd:?}"));
1062    match cmd {
1063        EditCommand::MoveToStart { select }
1064        | EditCommand::MoveToLineStart { select }
1065        | EditCommand::MoveToEnd { select }
1066        | EditCommand::MoveToLineEnd { select }
1067        | EditCommand::MoveLeft { select }
1068        | EditCommand::MoveRight { select }
1069        | EditCommand::MoveWordLeft { select }
1070        | EditCommand::MoveBigWordLeft { select }
1071        | EditCommand::MoveWordRight { select }
1072        | EditCommand::MoveWordRightStart { select }
1073        | EditCommand::MoveBigWordRightStart { select }
1074        | EditCommand::MoveWordRightEnd { select }
1075        | EditCommand::MoveBigWordRightEnd { select } => format!(
1076            "{:<max_cmd_len$} {}{}",
1077            cmd_highlight,
1078            cmd_desc_map
1079                .get(format!("{cmd:?}").split_once(' ').unwrap().0)
1080                .unwrap_or(&""),
1081            if *select {
1082                ". Select the text between the current cursor position and destination"
1083            } else {
1084                ", without selecting"
1085            }
1086        ),
1087        EditCommand::InsertString(_)
1088        | EditCommand::InsertNewline
1089        | EditCommand::ReplaceChar(_)
1090        | EditCommand::ReplaceChars(_, _)
1091        | EditCommand::Backspace
1092        | EditCommand::Delete
1093        | EditCommand::CutChar
1094        | EditCommand::BackspaceWord
1095        | EditCommand::DeleteWord
1096        | EditCommand::Clear
1097        | EditCommand::ClearToLineEnd
1098        | EditCommand::Complete
1099        | EditCommand::CutCurrentLine
1100        | EditCommand::CutFromStart
1101        | EditCommand::CutFromLineStart
1102        | EditCommand::CutToEnd
1103        | EditCommand::CutToLineEnd
1104        | EditCommand::CutWordLeft
1105        | EditCommand::CutBigWordLeft
1106        | EditCommand::CutWordRight
1107        | EditCommand::CutBigWordRight
1108        | EditCommand::CutWordRightToNext
1109        | EditCommand::CutBigWordRightToNext
1110        | EditCommand::PasteCutBufferBefore
1111        | EditCommand::PasteCutBufferAfter
1112        | EditCommand::UppercaseWord
1113        | EditCommand::InsertChar(_)
1114        | EditCommand::CapitalizeChar
1115        | EditCommand::SwitchcaseChar
1116        | EditCommand::SwapWords
1117        | EditCommand::SwapGraphemes
1118        | EditCommand::Undo
1119        | EditCommand::Redo
1120        | EditCommand::CutRightUntil(_)
1121        | EditCommand::CutRightBefore(_)
1122        | EditCommand::CutLeftUntil(_)
1123        | EditCommand::CutLeftBefore(_)
1124        | EditCommand::CutSelection
1125        | EditCommand::CopySelection
1126        | EditCommand::Paste
1127        | EditCommand::SelectAll
1128        | EditCommand::LowercaseWord => format!(
1129            "{:<max_cmd_len$} {}",
1130            cmd_highlight,
1131            cmd_desc_map.get(format!("{cmd:?}").as_str()).unwrap_or(&"")
1132        ),
1133        EditCommand::MoveRightUntil { c: _, select }
1134        | EditCommand::MoveRightBefore { c: _, select }
1135        | EditCommand::MoveLeftUntil { c: _, select }
1136        | EditCommand::MoveLeftBefore { c: _, select } => format!(
1137            "{:<max_cmd_len$} {}. {}",
1138            cmd_highlight,
1139            cmd_desc_map
1140                .get(format!("{cmd:?}").split_once(' ').unwrap().0)
1141                .unwrap_or(&""),
1142            if *select {
1143                "Select the text between the current cursor position and destination"
1144            } else {
1145                "without selecting"
1146            }
1147        ),
1148        EditCommand::MoveToPosition { position, select } => format!(
1149            "{:<max_cmd_len$} {} {} {}",
1150            cmd_highlight,
1151            cmd_desc_map
1152                .get(format!("{cmd:?}").split_once(' ').unwrap().0)
1153                .unwrap_or(&""),
1154            position,
1155            if *select {
1156                "Select the text between the current cursor position and destination"
1157            } else {
1158                "without selecting"
1159            }
1160        ),
1161        // Add other EditCommand variants and their descriptions here
1162        _ => format!("{:<width$}", cmd_highlight, width = max_cmd_len + 2),
1163    }
1164}
1165
1166/// Delete the temporary files used by the current REPL instance.
1167/// # Errors
1168/// Currently will not return any errors.
1169#[allow(clippy::unnecessary_wraps)]
1170#[profiled]
1171pub fn delete(build_state: &BuildState) -> ThagResult<Option<String>> {
1172    // let build_state = &context.build_state;
1173    let clean_up = clean_up(&build_state.source_path, &build_state.target_dir_path);
1174    if clean_up.is_ok()
1175        || (!&build_state.source_path.exists() && !&build_state.target_dir_path.exists())
1176    {
1177        vprtln!(V::QQ, "Deleted");
1178    } else {
1179        vprtln!(
1180            V::QQ,
1181            "Failed to delete all files - enter l(ist) to list remaining files"
1182        );
1183    }
1184    Ok(Some(String::from("End of delete")))
1185}
1186
1187/// Open the generated destination Rust source code file in an editor.
1188/// # Errors
1189/// Will return `Err` if there is an error editing the file.
1190#[allow(clippy::unnecessary_wraps)]
1191#[profiled]
1192pub fn edit(source_path: &PathBuf) -> ThagResult<Option<String>> {
1193    edit_file(source_path)?;
1194
1195    Ok(Some(String::from("End of source edit")))
1196}
1197
1198/// Open the generated Cargo.toml file in an editor.
1199/// # Errors
1200/// Will return `Err` if there is an error editing the file.
1201#[allow(clippy::unnecessary_wraps)]
1202#[profiled]
1203pub fn toml(build_state: &BuildState) -> ThagResult<Option<String>> {
1204    let cargo_toml_file = &build_state.cargo_toml_path;
1205    if cargo_toml_file.exists() {
1206        edit_file(cargo_toml_file)?;
1207    } else {
1208        vprtln!(V::QQ, "No Cargo.toml file found - have you run anything?");
1209    }
1210    Ok(Some(String::from("End of Cargo.toml edit")))
1211}
1212
1213/// Parse the current line. Borrowed from clap-repl crate.
1214#[must_use]
1215#[profiled]
1216pub fn parse_line(line: &str) -> (String, Vec<String>) {
1217    let re: &Regex = re!(r#"("[^"\n]+"|[\S]+)"#);
1218
1219    let mut args = re
1220        .captures_iter(line)
1221        .map(|a| a[0].to_string().replace('\"', ""))
1222        .collect::<Vec<String>>();
1223    let command: String = args.drain(..1).collect();
1224    (command, args)
1225}
1226
1227/// Display the REPL banner.
1228#[profiled]
1229pub fn disp_repl_banner(cmd_list: &str) {
1230    sprtln!(
1231        Role::HD1,
1232        r#"Enter a Rust expression (e.g., 2 + 3 or "Hi!"), or one of: {cmd_list}."#
1233    );
1234
1235    println!();
1236
1237    sprtln!(
1238        Role::HD2,
1239        r"Expressions in matching braces, brackets or quotes may span multiple lines."
1240    );
1241
1242    sprtln!(
1243        Role::HD2,
1244        r"Use F7 & F8 to navigate prev/next history, →  to select current. Ctrl-U: clear. Ctrl-K: delete to end."
1245    );
1246}
1247
1248/// Display a list of the temporary files used by the current REPL instance.
1249/// # Errors
1250/// This function will return an error in the following situations, but is not limited to just these cases:
1251/// The provided path doesn't exist.
1252/// The process lacks permissions to view the contents.
1253/// The path points at a non-directory file.
1254#[allow(clippy::unnecessary_wraps)]
1255#[profiled]
1256pub fn list(build_state: &BuildState) -> ThagResult<Option<String>> {
1257    let source_path = &build_state.source_path;
1258    if source_path.exists() {
1259        vprtln!(V::QQ, "File: {source_path:?}");
1260    }
1261
1262    // Display directory contents
1263    display_dir_contents(&build_state.target_dir_path)?;
1264
1265    // Check if neither file nor directory exist
1266    if !&source_path.exists() && !&build_state.target_dir_path.exists() {
1267        vprtln!(V::QQ, "No temporary files found");
1268    }
1269    Ok(Some(String::from("End of list")))
1270}