sigi/cli/
interact.rs

1use super::*;
2use crate::effects::StackEffect;
3use crate::output::OutputFormat;
4use clap::CommandFactory;
5use rustyline::error::ReadlineError;
6use rustyline::DefaultEditor;
7use std::str::FromStr;
8
9const HUMAN_PROMPT: &str = "🌴 ▶ ";
10
11pub const INTERACT_INSTRUCTIONS: &str = "INTERACTIVE MODE:
12
13Use subcommands in interactive mode directly. \
14No OPTIONS (flags) of subcommands are understood in interactive mode. \
15The ; character can be used to separate commands.
16
17The following additional commands are available:
18    ?               Show the short version of \"help\"
19    clear           Clear the terminal screen
20    use             Change to the specified stack [aliases: stack]
21    exit            Quit interactive mode [aliases: quit, q]";
22
23pub const INTERACT_LONG_INSTRUCTIONS: &str = "INTERACTIVE MODE:
24
25Use subcommands in interactive mode directly. For example:
26
27    🌴 ▶ push a new thing
28    Created: a new thing
29    🌴 ▶ peek
30    Now: a new thing
31    🌴 ▶ delete
32    Deleted: a new thing
33    Now: nothing
34    🌴 ▶ exit
35    exit: Buen biåhe!
36
37No OPTIONS (flags) of subcommands are understood in interactive mode.
38
39The ; character can be used to separate commands.
40
41In interactive mode, the following additional commands are available:
42    ?
43            Show the short version of \"help\"
44    clear   
45            Clear the terminal screen
46    use
47            Change to the specified stack [aliases: stack]
48    exit
49            Quit interactive mode [aliases: quit, q]";
50
51// TODO: pagination/scrollback?
52// TODO: more comprehensive tests
53pub fn interact(original_stack: String, data_store: DataStore, output: OutputFormat) {
54    print_welcome_msg(output);
55
56    let mut rl = DefaultEditor::new().expect("Unable to create readline.");
57    let prompt = if output.is_nonquiet_for_humans() {
58        HUMAN_PROMPT
59    } else {
60        ""
61    };
62
63    let mut stack = original_stack;
64
65    loop {
66        let line = rl.readline(prompt);
67
68        if let Ok(line) = &line {
69            rl.add_history_entry(line).unwrap();
70        }
71
72        use InteractAction::*;
73        let line = line.map_err(handle_error).map(handle_line(&stack));
74        let actions = match line {
75            Ok(actions) => actions,
76            Err(err_action) => vec![err_action],
77        };
78
79        for action in actions {
80            match action {
81                ShortHelp => Cli::command().print_help().unwrap(),
82                LongHelp => Cli::command().print_long_help().unwrap(),
83                Clear => clearscreen::clear().expect("Failed to clear screen"),
84                DoEffect(effect) => effect.run(&data_store, &output),
85                UseStack(new_stack) => {
86                    stack = new_stack;
87                    output.log(vec!["update", "stack"], vec![vec!["Active stack", &stack]]);
88                }
89                NoContent => (),
90                Exit(reason) => {
91                    print_goodbye_msg(&reason, output);
92                    return;
93                }
94                MissingArgument(msg) => {
95                    output.log(
96                        vec!["argument", "error"],
97                        vec![vec![&msg, "missing argument"]],
98                    );
99                }
100                Error(msg) => {
101                    output.log(
102                        vec!["exit-message", "exit-reason"],
103                        vec![vec!["Error"], vec![&msg]],
104                    );
105                    return;
106                }
107                Unknown(term) => {
108                    if output.is_nonquiet_for_humans() {
109                        println!("Oops, I don't know {:?}", term);
110                    } else {
111                        output.log(vec!["term", "error"], vec![vec![&term, "unknown term"]]);
112                    };
113                }
114            };
115        }
116    }
117}
118
119fn print_welcome_msg(output: OutputFormat) {
120    if output.is_nonquiet_for_humans() {
121        println!("sigi {}", SIGI_VERSION);
122        println!(
123            "Type \"exit\", \"quit\", or \"q\" to quit. (On Unixy systems, Ctrl+C or Ctrl+D also work)"
124        );
125        println!("Type \"?\" for quick help, or \"help\" for a more verbose help message.");
126        println!();
127    }
128}
129
130fn print_goodbye_msg(reason: &str, output: OutputFormat) {
131    output.log(
132        vec!["exit-reason", "exit-message"],
133        vec![vec![reason, "Buen biåhe!"]],
134    );
135}
136
137enum InteractAction {
138    ShortHelp,
139    LongHelp,
140    Clear,
141    DoEffect(StackEffect),
142    UseStack(String),
143    NoContent,
144    Exit(String),
145    MissingArgument(String),
146    Error(String),
147    Unknown(String),
148}
149
150fn handle_error(err: ReadlineError) -> InteractAction {
151    match err {
152        ReadlineError::Interrupted => InteractAction::Exit("Ctrl+c".to_string()),
153        ReadlineError::Eof => InteractAction::Exit("Ctrl+d".to_string()),
154        err => InteractAction::Error(format!("{:?}", err)),
155    }
156}
157
158fn handle_line(stack: &str) -> impl Fn(String) -> Vec<InteractAction> + '_ {
159    |line| {
160        line.split(';')
161            .map(|s| s.to_string())
162            .map(|line| parse_line(line, stack.to_string()))
163            .collect()
164    }
165}
166
167fn parse_line(line: String, stack: String) -> InteractAction {
168    let tokens = line.split_ascii_whitespace().collect::<Vec<_>>();
169
170    if tokens.is_empty() {
171        return InteractAction::NoContent;
172    }
173
174    let term = tokens.first().unwrap().to_ascii_lowercase();
175
176    match term.as_str() {
177        "?" => InteractAction::ShortHelp,
178        "help" => InteractAction::LongHelp,
179        "clear" => InteractAction::Clear,
180        "exit" | "quit" | "q" => InteractAction::Exit(term),
181        "use" | "stack" => match tokens.get(1) {
182            Some(stack) => InteractAction::UseStack(stack.to_string()),
183            None => InteractAction::MissingArgument("stack name".to_string()),
184        },
185        _ => match parse_effect(tokens, stack) {
186            ParseEffectResult::Effect(effect) => InteractAction::DoEffect(effect),
187            ParseEffectResult::NotEffect(parse_res) => parse_res,
188            ParseEffectResult::Unknown => InteractAction::Unknown(term),
189        },
190    }
191}
192
193enum ParseEffectResult {
194    Effect(StackEffect),
195    NotEffect(InteractAction),
196    Unknown,
197}
198
199fn parse_effect(tokens: Vec<&str>, stack: String) -> ParseEffectResult {
200    let term = tokens.first().unwrap_or(&"");
201
202    let parse_n = || tokens.get(1).and_then(|&s| usize::from_str(s).ok());
203
204    use ParseEffectResult::*;
205    use StackEffect::*;
206
207    if COMPLETE_TERMS.contains(term) {
208        let index = parse_n().unwrap_or(0);
209        return Effect(Complete { stack, index });
210    }
211    if COUNT_TERMS.contains(term) {
212        return Effect(Count { stack });
213    }
214    if DELETE_TERMS.contains(term) {
215        let index = parse_n().unwrap_or(0);
216        return Effect(Delete { stack, index });
217    }
218    if DELETE_ALL_TERMS.contains(term) {
219        return Effect(DeleteAll { stack });
220    }
221    if EDIT_TERMS.contains(term) {
222        let index = parse_n().unwrap_or(0);
223        return Effect(Edit {
224            stack,
225            editor: resolve_editor(None),
226            index,
227        });
228    }
229    if HEAD_TERMS.contains(term) {
230        let n = parse_n().unwrap_or(DEFAULT_SHORT_LIST_LIMIT);
231        return Effect(Head { stack, n });
232    }
233    if IS_EMPTY_TERMS.contains(term) {
234        return Effect(IsEmpty { stack });
235    }
236    if LIST_TERMS.contains(term) {
237        return Effect(ListAll { stack });
238    }
239    if LIST_STACKS_TERMS.contains(term) {
240        return Effect(ListStacks);
241    }
242    if MOVE_TERMS.contains(term) {
243        match tokens.get(1) {
244            Some(dest) => {
245                let dest = dest.to_string();
246                return Effect(Move { stack, dest });
247            }
248            None => {
249                return NotEffect(InteractAction::MissingArgument(
250                    "destination stack".to_string(),
251                ));
252            }
253        };
254    }
255    if MOVE_ALL_TERMS.contains(term) {
256        match tokens.get(1) {
257            Some(dest) => {
258                let dest = dest.to_string();
259                return Effect(MoveAll { stack, dest });
260            }
261            None => {
262                return NotEffect(InteractAction::MissingArgument(
263                    "destination stack".to_string(),
264                ));
265            }
266        };
267    }
268    if NEXT_TERMS.contains(term) {
269        return Effect(Next { stack });
270    }
271    if PEEK_TERMS.contains(term) {
272        return Effect(Peek { stack });
273    }
274    if PICK_TERMS.contains(term) {
275        let indices = tokens
276            .iter()
277            .filter_map(|s| usize::from_str(s).ok())
278            .collect();
279        return Effect(Pick { stack, indices });
280    }
281    if PUSH_TERMS.contains(term) {
282        // FIXME: This is convenient, but normalizes whitespace. (E.g. multiple spaces always collapsed, tabs to spaces, etc)
283        let content = tokens[1..].join(" ");
284        return Effect(Push { stack, content });
285    }
286    if ROT_TERMS.contains(term) {
287        return Effect(Rot { stack });
288    }
289    if SWAP_TERMS.contains(term) {
290        return Effect(Swap { stack });
291    }
292    if TAIL_TERMS.contains(term) {
293        let n = parse_n().unwrap_or(DEFAULT_SHORT_LIST_LIMIT);
294        return Effect(Tail { stack, n });
295    }
296
297    Unknown
298}