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};
15use 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
44pub const HISTORY_FILE: &str = "thag_repl_hist.txt";
46
47pub 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#[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)] #[strum(serialize_all = "snake_case")]
217#[allow(clippy::module_name_repetitions)]
218pub enum ReplCommand {
219 Banner,
221 Tui,
224 Edit,
227 Toml,
229 Run,
231 Delete,
233 List,
235 History,
237 Help,
239 Keys,
241 Theme,
243 Quit,
245}
246
247impl ReplCommand {
248 #[profiled]
249 fn print_help() {
250 let mut command = Self::command();
251 println!("{}", command.render_long_help());
255 }
256}
257
258#[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#[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#[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 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 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 if matches!(
395 term_attrs.color_support,
396 ColorSupport::None | ColorSupport::Undetermined
397 ) {
398 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 line_editor_builder = line_editor_builder.with_highlighter(highlighter);
409
410 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 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 .collect::<Vec<(String, &ReedlineEvent)>>();
440 let formatted_bindings = format_bindings(&named_reedline_events, max_cmd_len);
441
442 let max_key_len = lazy_static_var!(usize, deref, get_max_key_len(formatted_bindings));
444 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 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 continue;
471 }
472 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 if matches == 1 {
481 cmd = key.to_string();
482 }
483 }
485 }
486 if matches == 1 {
487 Some(cmd)
488 } else {
489 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 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#[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 ];
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 textarea,
645 edit_data,
646 popup,
647 saved,
648 status_message| {
649 script_key_handler(
650 key_event,
651 maybe_term, textarea,
653 edit_data,
654 popup,
655 saved,
656 status_message,
657 )
658 },
659 )?;
660 let _ = match key_action {
661 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::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 let _ = history_mut.save(HistoryItem::from_command_line(entry))?;
704 }
705 history_mut.sync()?;
706 }
707 Ok(())
708}
709
710#[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#[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 ];
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, 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#[profiled]
787pub fn history_key_handler(
788 key_event: KeyEvent,
789 _maybe_term: Option<&mut ManagedTerminal>,
790 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 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); 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_file(maybe_save_path.as_ref(), textarea)?;
810 Ok(KeyAction::SaveAndExit)
812 }
813 key!(ctrl - s) => {
814 let save_file = save_file(maybe_save_path.as_ref(), textarea)?;
816 *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 *popup = !*popup;
825 Ok(KeyAction::TogglePopup)
826 }
827 key!(f3) => {
828 Ok(KeyAction::AbandonChanges)
830 }
831 _ => {
832 textarea.input(Input::from(key_event)); 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#[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 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 lazy_static_var!(usize, deref, {
904 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 max_cmd_len + 2
930 })
931}
932
933#[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 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#[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#[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#[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#[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 _ => format!("{:<width$}", cmd_highlight, width = max_cmd_len + 2),
1163 }
1164}
1165
1166#[allow(clippy::unnecessary_wraps)]
1170#[profiled]
1171pub fn delete(build_state: &BuildState) -> ThagResult<Option<String>> {
1172 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#[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#[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#[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#[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#[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_dir_contents(&build_state.target_dir_path)?;
1264
1265 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}