Skip to main content

loam_console/
lib.rs

1//! Interaction model follows the idTech console (Quake, 1996).
2
3mod queue;
4
5pub use queue::{CommandLine, CommandQueue};
6
7use std::collections::{BTreeMap, HashMap, VecDeque};
8
9mod key;
10
11pub use key::Key;
12
13pub const MAX_HISTORY_LINES: usize = 2000;
14
15pub const MAX_INPUT_HISTORY: usize = 100;
16
17#[cfg(target_arch = "wasm32")]
18static ECHO_TO_BROWSER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
19
20/// wasm32 only; a no-op on native.
21pub fn set_console_echo(enabled: bool) {
22    #[cfg(target_arch = "wasm32")]
23    ECHO_TO_BROWSER.store(enabled, std::sync::atomic::Ordering::Relaxed);
24    #[cfg(not(target_arch = "wasm32"))]
25    let _ = enabled;
26}
27
28pub fn console_echo_enabled() -> bool {
29    #[cfg(target_arch = "wasm32")]
30    {
31        ECHO_TO_BROWSER.load(std::sync::atomic::Ordering::Relaxed)
32    }
33    #[cfg(not(target_arch = "wasm32"))]
34    {
35        false
36    }
37}
38
39#[derive(Clone, Debug)]
40pub struct HistoryLine {
41    pub kind: LineKind,
42    pub text: String,
43}
44
45impl HistoryLine {
46    pub fn input(text: impl Into<String>) -> Self {
47        Self {
48            kind: LineKind::Input,
49            text: text.into(),
50        }
51    }
52    pub fn output(text: impl Into<String>) -> Self {
53        Self {
54            kind: LineKind::Output,
55            text: text.into(),
56        }
57    }
58    pub fn error(text: impl Into<String>) -> Self {
59        Self {
60            kind: LineKind::Error,
61            text: text.into(),
62        }
63    }
64    pub fn system(text: impl Into<String>) -> Self {
65        Self {
66            kind: LineKind::System,
67            text: text.into(),
68        }
69    }
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum LineKind {
74    Input,
75    Output,
76    Error,
77    System,
78}
79
80pub struct ConsoleWriter {
81    lines: Vec<HistoryLine>,
82}
83
84impl ConsoleWriter {
85    pub fn new() -> Self {
86        Self { lines: Vec::new() }
87    }
88
89    pub fn line(&mut self, text: impl Into<String>) {
90        self.lines.push(HistoryLine::output(text));
91    }
92
93    pub fn error(&mut self, text: impl Into<String>) {
94        self.lines.push(HistoryLine::error(text));
95    }
96
97    pub fn take_lines(&mut self) -> Vec<HistoryLine> {
98        std::mem::take(&mut self.lines)
99    }
100}
101
102impl Default for ConsoleWriter {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108pub trait Command<Ctx>: 'static {
109    fn name(&self) -> &str;
110
111    fn help(&self) -> &str;
112
113    /// `\n` breaks paint as separate scrollback entries.
114    fn long_help(&self) -> String {
115        self.help().to_string()
116    }
117
118    fn arg_choices(&self, arg_index: usize) -> &[&'static str] {
119        let _ = arg_index;
120        &[]
121    }
122
123    /// `prior` is the arg tokens before the cursor.
124    fn arg_choices_ctx<'a>(&'a self, arg_index: usize, prior: &[&str]) -> &'a [&'static str] {
125        let _ = prior;
126        self.arg_choices(arg_index)
127    }
128
129    /// `key` carries no trailing `=`; empty means free-form.
130    fn arg_value_choices(&self, arg_index: usize, key: &str) -> &[&'static str] {
131        let _ = (arg_index, key);
132        &[]
133    }
134
135    fn arg_value_choices_ctx<'a>(
136        &'a self,
137        arg_index: usize,
138        key: &str,
139        prior: &[&str],
140    ) -> &'a [&'static str] {
141        let _ = prior;
142        self.arg_value_choices(arg_index, key)
143    }
144
145    /// Recoverable issues go to `out.error(..)`; unrecoverable ones return `Err`.
146    fn run(&mut self, args: &[&str], ctx: &mut Ctx, out: &mut ConsoleWriter) -> anyhow::Result<()>;
147}
148
149pub struct FnCommand<F> {
150    name: &'static str,
151    help: &'static str,
152    long_help: Option<&'static str>,
153    arg_choices: Vec<Vec<&'static str>>,
154    value_choices: HashMap<&'static str, Vec<&'static str>>,
155    f: F,
156}
157
158impl<F> FnCommand<F> {
159    /// One inner slice per position; trailing free-form args may be omitted.
160    pub fn with_args(mut self, choices: &[&[&'static str]]) -> Self {
161        self.arg_choices = choices.iter().map(|s| s.to_vec()).collect();
162        self
163    }
164
165    /// First Tab completes to `key=`, subsequent Tabs cycle these values.
166    pub fn with_value_choices(mut self, key: &'static str, values: &[&'static str]) -> Self {
167        self.value_choices.insert(key, values.to_vec());
168        self
169    }
170
171    pub fn with_long_help(mut self, long: &'static str) -> Self {
172        self.long_help = Some(long);
173        self
174    }
175}
176
177pub fn cmd<Ctx, F>(name: &'static str, help: &'static str, f: F) -> FnCommand<F>
178where
179    F: FnMut(&[&str], &mut Ctx, &mut ConsoleWriter) -> anyhow::Result<()> + 'static,
180{
181    FnCommand {
182        name,
183        help,
184        long_help: None,
185        arg_choices: Vec::new(),
186        value_choices: HashMap::new(),
187        f,
188    }
189}
190
191impl<Ctx, F> Command<Ctx> for FnCommand<F>
192where
193    F: FnMut(&[&str], &mut Ctx, &mut ConsoleWriter) -> anyhow::Result<()> + 'static,
194{
195    fn name(&self) -> &str {
196        self.name
197    }
198    fn help(&self) -> &str {
199        self.help
200    }
201    fn long_help(&self) -> String {
202        self.long_help
203            .map(str::to_string)
204            .unwrap_or_else(|| self.help.to_string())
205    }
206    fn arg_choices(&self, arg_index: usize) -> &[&'static str] {
207        self.arg_choices
208            .get(arg_index)
209            .map(|v| v.as_slice())
210            .unwrap_or(&[])
211    }
212    fn arg_value_choices(&self, _arg_index: usize, key: &str) -> &[&'static str] {
213        self.value_choices
214            .get(key)
215            .map(|v| v.as_slice())
216            .unwrap_or(&[])
217    }
218    fn run(&mut self, args: &[&str], ctx: &mut Ctx, out: &mut ConsoleWriter) -> anyhow::Result<()> {
219        (self.f)(args, ctx, out)
220    }
221}
222
223type ToggleHandler<Ctx> = Box<dyn FnMut(&mut Ctx, Option<bool>) -> anyhow::Result<()>>;
224
225type ChoiceHandler<Ctx> = Box<dyn FnMut(&mut Ctx, Option<&str>) -> anyhow::Result<()>>;
226
227type BareHandler<Ctx> = Box<dyn FnMut(&mut Ctx) -> anyhow::Result<()>>;
228
229type CustomHandler<Ctx> =
230    Box<dyn FnMut(&mut Ctx, &[&str], &mut ConsoleWriter) -> anyhow::Result<()>>;
231
232enum SubcommandKind<Ctx> {
233    Toggle {
234        handler: ToggleHandler<Ctx>,
235    },
236    Choice {
237        choices: Vec<&'static str>,
238        handler: ChoiceHandler<Ctx>,
239    },
240    Custom {
241        arg_choices: Vec<Vec<&'static str>>,
242        value_choices: HashMap<&'static str, Vec<&'static str>>,
243        handler: CustomHandler<Ctx>,
244    },
245}
246
247struct SubcommandEntry<Ctx> {
248    help: &'static str,
249    kind: SubcommandKind<Ctx>,
250}
251
252pub struct SubcommandSet<Ctx> {
253    name: &'static str,
254    help: &'static str,
255    subs: BTreeMap<&'static str, SubcommandEntry<Ctx>>,
256    name_cache: std::cell::OnceCell<Vec<&'static str>>,
257    bare: Option<BareHandler<Ctx>>,
258    long_help: Option<&'static str>,
259}
260
261impl<Ctx: 'static> SubcommandSet<Ctx> {
262    /// Parses `on|off|true|false|1|0`; a bare invocation passes `None`.
263    pub fn toggle<F>(mut self, name: &'static str, help: &'static str, handler: F) -> Self
264    where
265        F: FnMut(&mut Ctx, Option<bool>) -> anyhow::Result<()> + 'static,
266    {
267        self.name_cache.take();
268        self.subs.insert(
269            name,
270            SubcommandEntry {
271                help,
272                kind: SubcommandKind::Toggle {
273                    handler: Box::new(handler),
274                },
275            },
276        );
277        self
278    }
279
280    /// The handler receives the raw value string, unvalidated against `choices`.
281    pub fn choice<F>(
282        mut self,
283        name: &'static str,
284        help: &'static str,
285        choices: &[&'static str],
286        handler: F,
287    ) -> Self
288    where
289        F: FnMut(&mut Ctx, Option<&str>) -> anyhow::Result<()> + 'static,
290    {
291        self.name_cache.take();
292        self.subs.insert(
293            name,
294            SubcommandEntry {
295                help,
296                kind: SubcommandKind::Choice {
297                    choices: choices.to_vec(),
298                    handler: Box::new(handler),
299                },
300            },
301        );
302        self
303    }
304
305    /// `arg_choices[i]` completes the i-th arg after the subcommand name.
306    pub fn custom<F>(
307        mut self,
308        name: &'static str,
309        help: &'static str,
310        arg_choices: &[&[&'static str]],
311        value_choices: &[(&'static str, &[&'static str])],
312        handler: F,
313    ) -> Self
314    where
315        F: FnMut(&mut Ctx, &[&str], &mut ConsoleWriter) -> anyhow::Result<()> + 'static,
316    {
317        let mut vc = HashMap::new();
318        for (k, vs) in value_choices {
319            vc.insert(*k, vs.to_vec());
320        }
321        self.name_cache.take();
322        self.subs.insert(
323            name,
324            SubcommandEntry {
325                help,
326                kind: SubcommandKind::Custom {
327                    arg_choices: arg_choices.iter().map(|slot| slot.to_vec()).collect(),
328                    value_choices: vc,
329                    handler: Box::new(handler),
330                },
331            },
332        );
333        self
334    }
335
336    /// The subcommand table is appended either way.
337    pub fn with_long_help(mut self, long: &'static str) -> Self {
338        self.long_help = Some(long);
339        self
340    }
341
342    /// A bare command name runs `handler` instead of a usage error.
343    pub fn on_bare<F>(mut self, handler: F) -> Self
344    where
345        F: FnMut(&mut Ctx) -> anyhow::Result<()> + 'static,
346    {
347        self.bare = Some(Box::new(handler));
348        self
349    }
350
351    fn cached_names(&self) -> &[&'static str] {
352        self.name_cache
353            .get_or_init(|| self.subs.keys().copied().collect())
354    }
355}
356
357pub fn subcommands<Ctx: 'static>(name: &'static str, help: &'static str) -> SubcommandSet<Ctx> {
358    SubcommandSet {
359        name,
360        help,
361        subs: BTreeMap::new(),
362        name_cache: std::cell::OnceCell::new(),
363        bare: None,
364        long_help: None,
365    }
366}
367
368impl<Ctx: 'static> Command<Ctx> for SubcommandSet<Ctx> {
369    fn name(&self) -> &str {
370        self.name
371    }
372    fn help(&self) -> &str {
373        self.help
374    }
375
376    fn long_help(&self) -> String {
377        let preamble = self.long_help.unwrap_or(self.help);
378        let mut out = String::with_capacity(128 + preamble.len() + self.subs.len() * 64);
379        out.push_str(preamble);
380        if !self.subs.is_empty() {
381            out.push_str("\nsubcommands:");
382            for (name, entry) in &self.subs {
383                let kind = match entry.kind {
384                    SubcommandKind::Toggle { .. } => "<on|off>",
385                    SubcommandKind::Choice { .. } => "<choice>",
386                    SubcommandKind::Custom { .. } => "<args...>",
387                };
388                out.push_str(&format!("\n  {name:14} {kind:9}  {}", entry.help));
389            }
390        }
391        out
392    }
393
394    fn arg_choices(&self, arg_index: usize) -> &[&'static str] {
395        if arg_index == 0 {
396            self.cached_names()
397        } else {
398            &[]
399        }
400    }
401
402    fn arg_choices_ctx<'a>(&'a self, arg_index: usize, prior: &[&str]) -> &'a [&'static str] {
403        if arg_index == 0 {
404            return self.cached_names();
405        }
406        let Some(&sub_name) = prior.first() else {
407            return &[];
408        };
409        let Some(entry) = self.subs.get(sub_name) else {
410            return &[];
411        };
412        let sub_slot = arg_index - 1;
413        match &entry.kind {
414            SubcommandKind::Toggle { .. } => &[],
415            SubcommandKind::Choice { choices, .. } => {
416                if sub_slot == 0 {
417                    choices.as_slice()
418                } else {
419                    &[]
420                }
421            }
422            SubcommandKind::Custom { arg_choices, .. } => arg_choices
423                .get(sub_slot)
424                .map(|v| v.as_slice())
425                .unwrap_or(&[]),
426        }
427    }
428
429    fn arg_value_choices_ctx<'a>(
430        &'a self,
431        _arg_index: usize,
432        key: &str,
433        prior: &[&str],
434    ) -> &'a [&'static str] {
435        let Some(&sub_name) = prior.first() else {
436            return &[];
437        };
438        let Some(entry) = self.subs.get(sub_name) else {
439            return &[];
440        };
441        match &entry.kind {
442            SubcommandKind::Custom { value_choices, .. } => {
443                value_choices.get(key).map(|v| v.as_slice()).unwrap_or(&[])
444            }
445            _ => &[],
446        }
447    }
448
449    fn run(&mut self, args: &[&str], ctx: &mut Ctx, out: &mut ConsoleWriter) -> anyhow::Result<()> {
450        let Some((sub_name, rest)) = args.split_first() else {
451            if let Some(handler) = self.bare.as_mut() {
452                return handler(ctx);
453            }
454            let mut msg = format!("usage: {} <subcommand> <value>; subcommands:", self.name);
455            for (name, entry) in &self.subs {
456                msg.push_str(&format!("\n  {name:12} {}", entry.help));
457            }
458            return Err(anyhow::anyhow!(msg));
459        };
460        let Some(entry) = self.subs.get_mut(*sub_name) else {
461            let names: Vec<&str> = self.subs.keys().copied().collect();
462            return Err(anyhow::anyhow!(
463                "unknown subcommand `{sub_name}` for `{}` (try {})",
464                self.name,
465                names.join(", ")
466            ));
467        };
468        match &mut entry.kind {
469            SubcommandKind::Toggle { handler } => {
470                let v: Option<bool> = match rest.first() {
471                    None => None,
472                    Some(value) => match value.to_ascii_lowercase().as_str() {
473                        "on" | "true" | "1" => Some(true),
474                        "off" | "false" | "0" => Some(false),
475                        other => {
476                            return Err(anyhow::anyhow!(
477                                "unknown value `{other}` for `{} {sub_name}` (try on|off)",
478                                self.name
479                            ))
480                        }
481                    },
482                };
483                let _ = out;
484                handler(ctx, v)
485            }
486            SubcommandKind::Choice { handler, .. } => {
487                let value: Option<&str> = rest.first().copied();
488                let _ = out;
489                handler(ctx, value)
490            }
491            SubcommandKind::Custom { handler, .. } => handler(ctx, rest, out),
492        }
493    }
494}
495
496pub struct Console<Ctx> {
497    commands: BTreeMap<String, Box<dyn Command<Ctx>>>,
498    binds: BTreeMap<Key, String>,
499    toggle_key: Key,
500    history: VecDeque<HistoryLine>,
501    input: String,
502    input_history: VecDeque<String>,
503    input_history_pos: Option<usize>,
504    tab: Option<TabState>,
505    open: bool,
506    pending_focus: bool,
507    status: String,
508    detached: bool,
509    user_defocused: bool,
510    pending_cursor_to_end: bool,
511    pending: Vec<String>,
512}
513
514struct TabState {
515    matches: Vec<String>,
516    index: usize,
517    ctx: CompletionContext,
518}
519
520#[derive(Clone, Debug)]
521enum CompletionContext {
522    Command {
523        prefix: String,
524    },
525    Arg {
526        cmd_name: String,
527        arg_index: usize,
528        prior: Vec<String>,
529        prefix: String,
530        start: usize,
531    },
532}
533
534impl CompletionContext {
535    fn prefix(&self) -> &str {
536        match self {
537            CompletionContext::Command { prefix } => prefix,
538            CompletionContext::Arg { prefix, .. } => prefix,
539        }
540    }
541}
542
543impl<Ctx: 'static> Default for Console<Ctx> {
544    fn default() -> Self {
545        Self::new()
546    }
547}
548
549impl<Ctx: 'static> Console<Ctx> {
550    pub fn new() -> Self {
551        Self {
552            commands: BTreeMap::new(),
553            binds: BTreeMap::new(),
554            toggle_key: Key::Backtick,
555            history: VecDeque::new(),
556            input: String::new(),
557            input_history: VecDeque::new(),
558            input_history_pos: None,
559            tab: None,
560            open: false,
561            pending_focus: false,
562            status: String::new(),
563            detached: false,
564            user_defocused: false,
565            pending_cursor_to_end: false,
566            pending: Vec::new(),
567        }
568    }
569
570    pub fn with_toggle_key(mut self, key: Key) -> Self {
571        self.toggle_key = key;
572        self
573    }
574
575    /// Silently replaces any existing command of the same name.
576    pub fn register<C: Command<Ctx> + 'static>(&mut self, command: C) {
577        let name = command.name().to_string();
578        self.commands.insert(name, Box::new(command));
579    }
580
581    pub fn has_command(&self, name: &str) -> bool {
582        Builtin::from_name(name).is_some() || self.commands.contains_key(name)
583    }
584
585    /// No modifiers; fires only while the console is closed. Re-binding overwrites.
586    pub fn bind(&mut self, key: Key, command_line: impl Into<String>) {
587        self.binds.insert(key, command_line.into());
588    }
589
590    pub fn unbind(&mut self, key: Key) {
591        self.binds.remove(&key);
592    }
593
594    pub fn open(&mut self) {
595        if !self.open {
596            self.open = true;
597            self.pending_focus = true;
598            self.user_defocused = false;
599        }
600    }
601
602    pub fn close(&mut self) {
603        self.open = false;
604    }
605
606    pub fn toggle(&mut self) {
607        if self.open {
608            self.close()
609        } else {
610            self.open()
611        }
612    }
613
614    pub fn is_open(&self) -> bool {
615        self.open
616    }
617
618    pub fn detach(&mut self) {
619        self.detached = true;
620    }
621
622    pub fn dock(&mut self) {
623        self.detached = false;
624    }
625
626    pub fn is_detached(&self) -> bool {
627        self.detached
628    }
629
630    pub fn write(&mut self, line: HistoryLine) {
631        self.push_history(line);
632    }
633
634    pub fn set_status(&mut self, text: impl Into<String>) {
635        self.status = text.into();
636    }
637
638    pub fn toggle_key(&self) -> Key {
639        self.toggle_key
640    }
641
642    /// In [`Key`] order.
643    pub fn binds(&self) -> impl Iterator<Item = (Key, &str)> {
644        self.binds.iter().map(|(key, line)| (*key, line.as_str()))
645    }
646
647    pub fn history(&self) -> &VecDeque<HistoryLine> {
648        &self.history
649    }
650
651    pub fn input(&self) -> &str {
652        &self.input
653    }
654
655    /// An edit through this must be followed by [`Console::cancel_tab_cycle`].
656    pub fn input_mut(&mut self) -> &mut String {
657        &mut self.input
658    }
659
660    pub fn status(&self) -> &str {
661        &self.status
662    }
663
664    pub fn submit(&mut self) {
665        let line = std::mem::take(&mut self.input);
666        self.execute(&line);
667    }
668
669    pub fn clear_history(&mut self) {
670        self.history.clear();
671    }
672
673    pub fn clear_input(&mut self) {
674        self.input.clear();
675        self.input_history_pos = None;
676        self.tab = None;
677    }
678
679    pub fn cancel_tab_cycle(&mut self) {
680        self.tab = None;
681    }
682
683    pub fn take_pending_focus(&mut self) -> bool {
684        std::mem::take(&mut self.pending_focus)
685    }
686
687    pub fn wants_persistent_focus(&self) -> bool {
688        !self.detached && !self.user_defocused
689    }
690
691    pub fn set_user_defocused(&mut self, defocused: bool) {
692        self.user_defocused = defocused;
693    }
694
695    pub fn take_pending_cursor_to_end(&mut self) -> bool {
696        std::mem::take(&mut self.pending_cursor_to_end)
697    }
698
699    pub fn history_prev(&mut self) {
700        if self.input_history.is_empty() {
701            return;
702        }
703        let pos = match self.input_history_pos {
704            None => self.input_history.len() - 1,
705            Some(0) => 0,
706            Some(p) => p - 1,
707        };
708        self.input.clone_from(&self.input_history[pos]);
709        self.input_history_pos = Some(pos);
710        self.tab = None;
711        self.pending_cursor_to_end = true;
712    }
713
714    pub fn history_next(&mut self) {
715        let Some(pos) = self.input_history_pos else {
716            return;
717        };
718        if pos + 1 >= self.input_history.len() {
719            self.input.clear();
720            self.input_history_pos = None;
721        } else {
722            self.input_history_pos = Some(pos + 1);
723            self.input.clone_from(&self.input_history[pos + 1]);
724        }
725        self.tab = None;
726        self.pending_cursor_to_end = true;
727    }
728
729    pub fn tab_complete(&mut self) {
730        if let Some(tab) = self.tab.as_mut() {
731            if !tab.matches.is_empty() {
732                tab.index = (tab.index + 1) % tab.matches.len();
733                let new_input = apply_completion(&self.input, &tab.ctx, &tab.matches[tab.index]);
734                self.input = new_input;
735                self.pending_cursor_to_end = true;
736                return;
737            }
738        }
739        let ctx = self
740            .completion_context()
741            .unwrap_or(CompletionContext::Command {
742                prefix: String::new(),
743            });
744        let matches = self.completion_matches(&ctx);
745        if matches.is_empty() {
746            return;
747        }
748        self.input = apply_completion(&self.input, &ctx, &matches[0]);
749        self.pending_cursor_to_end = true;
750        if matches.len() > 1 {
751            self.tab = Some(TabState {
752                matches,
753                index: 0,
754                ctx,
755            });
756        } else {
757            self.tab = None;
758        }
759    }
760
761    fn push_history(&mut self, line: HistoryLine) {
762        // Not via `tracing`: `loam_app::log::ConsoleLayer` would echo it back here.
763        #[cfg(target_arch = "wasm32")]
764        if ECHO_TO_BROWSER.load(std::sync::atomic::Ordering::Relaxed) {
765            web_sys::console::log_1(&line.text.as_str().into());
766        }
767        self.history.push_back(line);
768        while self.history.len() > MAX_HISTORY_LINES {
769            self.history.pop_front();
770        }
771    }
772
773    fn all_command_names(&self) -> Vec<String> {
774        let mut names: Vec<String> = self.commands.keys().cloned().collect();
775        names.extend(Builtin::ALL.iter().map(|b| b.name().to_string()));
776        names.sort();
777        names
778    }
779
780    fn completion_context(&self) -> Option<CompletionContext> {
781        if self.input.is_empty() {
782            return None;
783        }
784        let parsed = tokenize_spans(&self.input);
785        if parsed.is_empty() {
786            return None;
787        }
788        let trailing_ws = parsed.last()?.end < self.input.len();
789
790        if !trailing_ws {
791            if let [only] = parsed.as_slice() {
792                return Some(CompletionContext::Command {
793                    prefix: only.value.clone(),
794                });
795            }
796        }
797
798        let mut parts = parsed;
799        let cmd_name = parts.remove(0).value;
800        let (arg_index, prefix, start) = if trailing_ws {
801            let idx = parts.len();
802            (idx, String::new(), self.input.len())
803        } else {
804            let partial = parts.pop()?;
805            (parts.len(), partial.value, partial.start)
806        };
807        Some(CompletionContext::Arg {
808            cmd_name,
809            arg_index,
810            prior: parts.into_iter().map(|part| part.value).collect(),
811            prefix,
812            start,
813        })
814    }
815
816    fn completion_matches(&self, ctx: &CompletionContext) -> Vec<String> {
817        match ctx {
818            CompletionContext::Command { prefix } => self
819                .all_command_names()
820                .into_iter()
821                .filter(|name| name.starts_with(prefix.as_str()))
822                .collect(),
823            CompletionContext::Arg {
824                cmd_name,
825                arg_index,
826                prior,
827                prefix,
828                ..
829            } => {
830                let Some(cmd) = self.commands.get(cmd_name) else {
831                    return Vec::new();
832                };
833                let prior_refs: Vec<&str> = prior.iter().map(String::as_str).collect();
834
835                if let Some(eq) = prefix.find('=') {
836                    let key = &prefix[..eq];
837                    let value_prefix = &prefix[eq + 1..];
838                    let mut matches: Vec<String> = cmd
839                        .arg_value_choices_ctx(*arg_index, key, &prior_refs)
840                        .iter()
841                        .filter(|v| v.starts_with(value_prefix))
842                        .map(|v| format!("{key}={v}"))
843                        .collect();
844                    matches.sort();
845                    return matches;
846                }
847
848                let used_kv_prefixes: Vec<&str> = prior
849                    .iter()
850                    .filter_map(|t| t.find('=').map(|i| &t[..=i]))
851                    .collect();
852
853                let mut matches: Vec<String> = cmd
854                    .arg_choices_ctx(*arg_index, &prior_refs)
855                    .iter()
856                    .filter(|choice| choice.starts_with(prefix.as_str()))
857                    .filter(|choice| match choice.find('=') {
858                        None => true,
859                        Some(eq) => {
860                            let key = &choice[..=eq];
861                            !used_kv_prefixes.contains(&key)
862                        }
863                    })
864                    .map(|choice| (*choice).to_string())
865                    .collect();
866                matches.sort();
867                matches
868            }
869        }
870    }
871
872    /// Suffix of the first (sort-order) completion; what the next Tab inserts.
873    pub fn tab_preview(&self) -> Option<String> {
874        let ctx = self.completion_context()?;
875        let matches = self.completion_matches(&ctx);
876        let first = matches.first()?;
877        let prefix_len = ctx.prefix().len();
878        if first.len() > prefix_len {
879            Some(first[prefix_len..].to_string())
880        } else {
881            None
882        }
883    }
884
885    /// Runs a built-in on the spot; parks anything else for [`Console::drain_pending`].
886    pub fn execute(&mut self, line: &str) {
887        let line = line.trim();
888        if line.is_empty() {
889            return;
890        }
891
892        if self.input_history.back().map(String::as_str) != Some(line) {
893            self.input_history.push_back(line.to_string());
894            while self.input_history.len() > MAX_INPUT_HISTORY {
895                self.input_history.pop_front();
896            }
897        }
898        self.input_history_pos = None;
899        self.tab = None;
900
901        let Some((name, args)) = parse_line(line) else {
902            return;
903        };
904
905        if let Some(builtin) = Builtin::from_name(&name) {
906            self.push_history(HistoryLine::input(format!("> {line}")));
907            self.run_builtin(builtin, args.first().map(String::as_str));
908            return;
909        }
910
911        self.pending.push(line.to_string());
912    }
913
914    pub fn drain_pending(&mut self) -> Vec<String> {
915        std::mem::take(&mut self.pending)
916    }
917
918    /// Built-ins resolve here too, so a queued built-in runs like a typed one.
919    pub fn dispatch(&mut self, name: &str, args: &[&str], ctx: &mut Ctx) {
920        self.push_history(HistoryLine::input(format!("> {}", render_line(name, args))));
921        if let Some(builtin) = Builtin::from_name(name) {
922            self.run_builtin(builtin, args.first().copied());
923            return;
924        }
925
926        let mut writer = ConsoleWriter::new();
927        let result = match self.commands.get_mut(name) {
928            Some(cmd) => cmd.run(args, ctx, &mut writer),
929            None => {
930                self.push_history(HistoryLine::error(format!(
931                    "no command '{name}'. try: help"
932                )));
933                return;
934            }
935        };
936        for hl in writer.lines {
937            self.push_history(hl);
938        }
939        if let Err(e) = result {
940            self.push_history(HistoryLine::error(format!("error: {e:#}")));
941        }
942    }
943
944    fn run_builtin(&mut self, builtin: Builtin, target: Option<&str>) {
945        match builtin {
946            Builtin::Help => self.builtin_help(target),
947            Builtin::Clear => self.history.clear(),
948            Builtin::Detach => {
949                self.detached = true;
950                self.push_history(HistoryLine::system("console detached"));
951            }
952            Builtin::Dock => {
953                self.detached = false;
954                self.push_history(HistoryLine::system("console docked"));
955            }
956        }
957    }
958
959    fn builtin_help(&mut self, target: Option<&str>) {
960        match target {
961            Some(name) => {
962                if let Some(b) = Builtin::from_name(name) {
963                    self.push_history(HistoryLine::output(format!("{}: {}", b.name(), b.help())));
964                } else {
965                    let prepared: Option<Vec<String>> = self.commands.get(name).map(|c| {
966                        let header_prefix = format!("{}: ", c.name());
967                        let body = c.long_help();
968                        let indent = " ".repeat(c.name().len() + 2);
969                        let mut lines = body.lines();
970                        let first = lines.next().unwrap_or("");
971                        let mut rendered = vec![format!("{header_prefix}{first}")];
972                        for line in lines {
973                            rendered.push(format!("{indent}{line}"));
974                        }
975                        rendered
976                    });
977                    if let Some(lines) = prepared {
978                        for line in lines {
979                            self.push_history(HistoryLine::output(line));
980                        }
981                    } else {
982                        self.push_history(HistoryLine::error(format!("no command '{name}'")));
983                    }
984                }
985            }
986            None => {
987                self.push_history(HistoryLine::output("commands:"));
988                let mut entries: Vec<(String, String)> = self
989                    .commands
990                    .values()
991                    .map(|c| (c.name().to_string(), c.help().to_string()))
992                    .collect();
993                for b in Builtin::ALL {
994                    entries.push((b.name().to_string(), b.help().to_string()));
995                }
996                entries.sort_by(|a, b| a.0.cmp(&b.0));
997                for (name, help) in entries {
998                    self.push_history(HistoryLine::output(format!("  {name:16} {help}")));
999                }
1000            }
1001        }
1002    }
1003}
1004
1005#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1006enum Builtin {
1007    Help,
1008    Clear,
1009    Detach,
1010    Dock,
1011}
1012
1013impl Builtin {
1014    // Sorted by `name()`.
1015    const ALL: &'static [Builtin] = &[
1016        Builtin::Clear,
1017        Builtin::Detach,
1018        Builtin::Dock,
1019        Builtin::Help,
1020    ];
1021
1022    fn from_name(name: &str) -> Option<Builtin> {
1023        match name {
1024            "help" => Some(Builtin::Help),
1025            "clear" => Some(Builtin::Clear),
1026            "detach" => Some(Builtin::Detach),
1027            "dock" => Some(Builtin::Dock),
1028            _ => None,
1029        }
1030    }
1031
1032    fn name(self) -> &'static str {
1033        match self {
1034            Builtin::Help => "help",
1035            Builtin::Clear => "clear",
1036            Builtin::Detach => "detach",
1037            Builtin::Dock => "dock",
1038        }
1039    }
1040
1041    fn help(self) -> &'static str {
1042        match self {
1043            Builtin::Help => "list commands or describe one",
1044            Builtin::Clear => "clear the scrollback buffer",
1045            Builtin::Detach => "render as a draggable window",
1046            Builtin::Dock => "render as a half-screen drop-down (default)",
1047        }
1048    }
1049}
1050
1051/// Double quotes honor `\"` and `\\`, single quotes are literal, and an open
1052/// quote runs to the end of the line.
1053pub fn parse_line(line: &str) -> Option<(String, Vec<String>)> {
1054    let mut tokens = tokenize(line);
1055    if tokens.is_empty() {
1056        return None;
1057    }
1058    let name = tokens.remove(0);
1059    Some((name, tokens))
1060}
1061
1062/// Inverse of [`parse_line`].
1063pub fn render_line(name: &str, args: &[&str]) -> String {
1064    let mut line = quote_token(name);
1065    for arg in args {
1066        line.push(' ');
1067        line.push_str(&quote_token(arg));
1068    }
1069    line
1070}
1071
1072fn quote_token(token: &str) -> String {
1073    let bare = !token.is_empty()
1074        && !token
1075            .chars()
1076            .any(|c| c.is_whitespace() || c == '"' || c == '\'');
1077    if bare {
1078        return token.to_string();
1079    }
1080    let mut quoted = String::with_capacity(token.len() + 2);
1081    quoted.push('"');
1082    for c in token.chars() {
1083        if c == '"' || c == '\\' {
1084            quoted.push('\\');
1085        }
1086        quoted.push(c);
1087    }
1088    quoted.push('"');
1089    quoted
1090}
1091
1092fn tokenize(line: &str) -> Vec<String> {
1093    tokenize_spans(line)
1094        .into_iter()
1095        .map(|token| token.value)
1096        .collect()
1097}
1098
1099struct Token {
1100    value: String,
1101    start: usize,
1102    end: usize,
1103}
1104
1105fn tokenize_spans(line: &str) -> Vec<Token> {
1106    let mut out = Vec::new();
1107    let mut cur = String::new();
1108    let mut start = None;
1109    let mut chars = line.char_indices().peekable();
1110    while let Some((offset, c)) = chars.next() {
1111        if !c.is_whitespace() {
1112            start.get_or_insert(offset);
1113        }
1114        match c {
1115            '"' => {
1116                while let Some(&(_, next)) = chars.peek() {
1117                    chars.next();
1118                    if next == '"' {
1119                        break;
1120                    }
1121                    if next == '\\' {
1122                        if let Some(&(_, escaped)) = chars.peek() {
1123                            if matches!(escaped, '"' | '\\') {
1124                                cur.push(escaped);
1125                                chars.next();
1126                                continue;
1127                            }
1128                        }
1129                    }
1130                    cur.push(next);
1131                }
1132            }
1133            '\'' => {
1134                while let Some(&(_, next)) = chars.peek() {
1135                    chars.next();
1136                    if next == '\'' {
1137                        break;
1138                    }
1139                    cur.push(next);
1140                }
1141            }
1142            c if c.is_whitespace() => {
1143                if let Some(start) = start.take() {
1144                    out.push(Token {
1145                        value: std::mem::take(&mut cur),
1146                        start,
1147                        end: offset,
1148                    });
1149                }
1150            }
1151            c => {
1152                cur.push(c);
1153            }
1154        }
1155    }
1156    if let Some(start) = start {
1157        out.push(Token {
1158            value: cur,
1159            start,
1160            end: line.len(),
1161        });
1162    }
1163    out
1164}
1165
1166fn apply_completion(input: &str, ctx: &CompletionContext, choice: &str) -> String {
1167    match ctx {
1168        CompletionContext::Command { .. } => choice.to_string(),
1169        CompletionContext::Arg { start, .. } => {
1170            format!("{}{}", &input[..*start], quote_token(choice))
1171        }
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178
1179    type Ctx = u32;
1180
1181    #[test]
1182    fn argument_completion_preserves_multibyte_whitespace() {
1183        let mut console = Console::<()>::new();
1184        console.register(cmd("mode", "set mode", |_, _, _| Ok(())).with_args(&[&["solid"]]));
1185        *console.input_mut() = "mode\u{2003}so".to_owned();
1186        console.tab_complete();
1187        assert_eq!(console.input(), "mode\u{2003}solid");
1188    }
1189
1190    #[test]
1191    fn quoted_completion_preserves_tokens_and_cycles_matches() {
1192        for input in [
1193            "mode \"solid s",
1194            "mode 'solid s'",
1195            "mode \"solid ",
1196            "mode solid",
1197        ] {
1198            let mut console = Console::<()>::new();
1199            console.register(
1200                cmd("mode", "set mode", |_, _, _| Ok(()))
1201                    .with_args(&[&["solid state", "solid surface"]]),
1202            );
1203            *console.input_mut() = input.to_owned();
1204            console.tab_complete();
1205            assert_eq!(parse_line(console.input()).unwrap().1, ["solid state"]);
1206            console.tab_complete();
1207            assert_eq!(parse_line(console.input()).unwrap().1, ["solid surface"]);
1208        }
1209
1210        let mut console = Console::<()>::new();
1211        console.register(cmd("mode", "set mode", |_, _, _| Ok(())).with_args(&[&["a\"b", "a\\b"]]));
1212        *console.input_mut() = "mode \"a\\\"".to_owned();
1213        console.tab_complete();
1214        assert_eq!(parse_line(console.input()).unwrap().1, ["a\"b"]);
1215    }
1216
1217    #[test]
1218    fn extending_subcommands_invalidates_completion_names() {
1219        let set = subcommands::<()>("view", "view").toggle("grid", "grid", |_, _| Ok(()));
1220        assert_eq!(set.arg_choices(0), &["grid"]);
1221        let set = set.toggle("axes", "axes", |_, _| Ok(()));
1222        assert_eq!(set.arg_choices(0), &["axes", "grid"]);
1223    }
1224    fn run<C: 'static>(console: &mut Console<C>, line: &str, ctx: &mut C) {
1225        console.execute(line);
1226        drain_and_dispatch(console, ctx);
1227    }
1228
1229    fn submit_and_run<C: 'static>(console: &mut Console<C>, ctx: &mut C) {
1230        console.submit();
1231        drain_and_dispatch(console, ctx);
1232    }
1233
1234    fn drain_and_dispatch<C: 'static>(console: &mut Console<C>, ctx: &mut C) {
1235        for line in console.drain_pending() {
1236            let Some((name, args)) = parse_line(&line) else {
1237                continue;
1238            };
1239            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1240            console.dispatch(&name, &arg_refs, ctx);
1241        }
1242    }
1243
1244    fn echo_cmd() -> impl Command<Ctx> {
1245        cmd("echo", "echo args back", |args, _ctx, out| {
1246            out.line(args.join(" "));
1247            Ok(())
1248        })
1249    }
1250
1251    fn add_cmd() -> impl Command<Ctx> {
1252        cmd("add", "add args to ctx", |args, ctx, out| {
1253            for a in args {
1254                let n: u32 = a.parse()?;
1255                *ctx += n;
1256            }
1257            out.line(format!("ctx={ctx}"));
1258            Ok(())
1259        })
1260    }
1261
1262    #[test]
1263    fn tab_preview_shows_first_match_suffix() {
1264        let mut c = Console::<Ctx>::new();
1265
1266        c.input = String::new();
1267        assert_eq!(c.tab_preview(), None);
1268
1269        c.input = "de".into();
1270        assert_eq!(c.tab_preview().as_deref(), Some("tach"));
1271
1272        c.input = "do".into();
1273        assert_eq!(c.tab_preview().as_deref(), Some("ck"));
1274
1275        c.input = "cl".into();
1276        assert_eq!(c.tab_preview().as_deref(), Some("ear"));
1277
1278        c.input = "h".into();
1279        assert_eq!(c.tab_preview().as_deref(), Some("elp"));
1280        c.input = "d".into();
1281        assert_eq!(c.tab_preview().as_deref(), Some("etach"));
1282
1283        c.input = "zzz".into();
1284        assert_eq!(c.tab_preview(), None);
1285    }
1286
1287    #[test]
1288    fn tab_preview_completes_declared_arg_choices() {
1289        let mut c = Console::<Ctx>::new();
1290        c.register(cmd("capture", "", |_, _, _| Ok(())).with_args(&[
1291            &["png", "frames", "toggle", "stop"],
1292            &["pre", "post", "both"],
1293        ]));
1294
1295        c.input = "capture p".into();
1296        assert_eq!(c.tab_preview().as_deref(), Some("ng"));
1297
1298        c.input = "capture t".into();
1299        assert_eq!(c.tab_preview().as_deref(), Some("oggle"));
1300        c.input = "capture png ".into();
1301        assert_eq!(c.tab_preview().as_deref(), Some("both"));
1302
1303        c.input = "capture png po".into();
1304        assert_eq!(c.tab_preview().as_deref(), Some("st"));
1305
1306        c.input = "capture png post extra ".into();
1307        assert_eq!(c.tab_preview(), None);
1308    }
1309
1310    #[test]
1311    fn two_step_kv_value_completion() {
1312        let mut c = Console::<Ctx>::new();
1313        c.register(
1314            cmd("capture", "", |_, _, _| Ok(()))
1315                .with_args(&[&["fps=", "palette="]])
1316                .with_value_choices("palette", &["local", "global"]),
1317        );
1318
1319        c.input = "capture pal".into();
1320        assert_eq!(c.tab_preview().as_deref(), Some("ette="));
1321        c.tab_complete();
1322        assert_eq!(c.input, "capture palette=");
1323
1324        let ctx = c.completion_context().unwrap();
1325        let matches = c.completion_matches(&ctx);
1326        assert_eq!(matches, vec!["palette=global", "palette=local"]);
1327
1328        c.input = "capture fps=".into();
1329        let ctx = c.completion_context().unwrap();
1330        let matches = c.completion_matches(&ctx);
1331        assert!(
1332            matches.is_empty(),
1333            "fps= should suggest no values; got {matches:?}"
1334        );
1335        assert_eq!(c.tab_preview(), None);
1336    }
1337
1338    #[test]
1339    fn arg_completion_filters_already_used_kv_prefixes() {
1340        let mut c = Console::<Ctx>::new();
1341        c.register(cmd("rec", "", |_, _, _| Ok(())).with_args(&[
1342            &["both", "fps=", "post", "scale="],
1343            &["fps=", "scale="],
1344            &["fps=", "scale="],
1345        ]));
1346
1347        c.input = "rec ".into();
1348        let ctx = c.completion_context().unwrap();
1349        let m = c.completion_matches(&ctx);
1350        assert!(m.contains(&"fps=".into()));
1351        assert!(m.contains(&"scale=".into()));
1352
1353        c.input = "rec fps=30 ".into();
1354        let ctx = c.completion_context().unwrap();
1355        let m = c.completion_matches(&ctx);
1356        assert!(!m.contains(&"fps=".into()), "got matches: {m:?}");
1357        assert!(m.contains(&"scale=".into()));
1358
1359        c.input = "rec fps=30 scale=720 ".into();
1360        let ctx = c.completion_context().unwrap();
1361        let m = c.completion_matches(&ctx);
1362        assert!(m.is_empty(), "got matches: {m:?}");
1363    }
1364
1365    #[test]
1366    fn tab_complete_applies_arg_choice() {
1367        let mut c = Console::<Ctx>::new();
1368        c.register(cmd("capture", "", |_, _, _| Ok(())).with_args(&[
1369            &["png", "frames", "toggle", "stop"],
1370            &["pre", "post", "both"],
1371        ]));
1372
1373        c.input = "capture p".into();
1374        c.tab_complete();
1375        assert_eq!(c.input, "capture png");
1376
1377        c.input = "capture png p".into();
1378        c.tab_complete();
1379        assert_eq!(c.input, "capture png post");
1380        c.tab_complete();
1381        assert_eq!(c.input, "capture png pre");
1382    }
1383
1384    #[test]
1385    fn builtin_help_describes_one_command() {
1386        let mut c = Console::<Ctx>::new();
1387        c.register(echo_cmd());
1388        let mut ctx: Ctx = 0;
1389        run(&mut c, "help echo", &mut ctx);
1390        let last = c.history.back().unwrap();
1391        assert_eq!(last.kind, LineKind::Output);
1392        assert!(last.text.contains("echo"));
1393        assert!(last.text.contains("echo args back"));
1394    }
1395
1396    #[test]
1397    fn input_history_appends_and_dedupes_consecutive() {
1398        let mut c = Console::<Ctx>::new();
1399        c.register(echo_cmd());
1400        let mut ctx: Ctx = 0;
1401        run(&mut c, "echo a", &mut ctx);
1402        run(&mut c, "echo a", &mut ctx);
1403        run(&mut c, "echo b", &mut ctx);
1404        let h: Vec<&str> = c.input_history.iter().map(String::as_str).collect();
1405        assert_eq!(h, vec!["echo a", "echo b"]);
1406    }
1407
1408    #[test]
1409    fn input_history_caps_at_max() {
1410        let mut c = Console::<Ctx>::new();
1411        c.register(echo_cmd());
1412        let mut ctx: Ctx = 0;
1413        for i in 0..(MAX_INPUT_HISTORY + 50) {
1414            run(&mut c, &format!("echo n{i}"), &mut ctx);
1415        }
1416        assert_eq!(c.input_history.len(), MAX_INPUT_HISTORY);
1417        assert!(c.input_history.front().unwrap().starts_with("echo n50"));
1418    }
1419
1420    #[test]
1421    fn history_caps_at_max() {
1422        let mut c = Console::<Ctx>::new();
1423        for i in 0..(MAX_HISTORY_LINES + 100) {
1424            c.push_history(HistoryLine::output(i.to_string()));
1425        }
1426        assert_eq!(c.history.len(), MAX_HISTORY_LINES);
1427        assert_eq!(c.history.front().unwrap().text, "100");
1428        assert_eq!(
1429            c.history.back().unwrap().text,
1430            (MAX_HISTORY_LINES + 99).to_string()
1431        );
1432    }
1433
1434    #[test]
1435    fn history_prev_walks_backwards_then_history_next_returns_to_blank() {
1436        let mut c = Console::<Ctx>::new();
1437        c.register(echo_cmd());
1438        let mut ctx: Ctx = 0;
1439        run(&mut c, "echo first", &mut ctx);
1440        run(&mut c, "echo second", &mut ctx);
1441
1442        c.history_prev();
1443        assert_eq!(c.input, "echo second");
1444        c.history_prev();
1445        assert_eq!(c.input, "echo first");
1446        c.history_prev();
1447        assert_eq!(c.input, "echo first");
1448        c.history_next();
1449        assert_eq!(c.input, "echo second");
1450        c.history_next();
1451        assert_eq!(c.input, "");
1452    }
1453
1454    #[test]
1455    fn tab_complete_ambiguous_prefix_cycles() {
1456        let mut c = Console::<Ctx>::new();
1457        c.register(cmd::<Ctx, _>("capture.start", "x", |_, _, _| Ok(())));
1458        c.register(cmd::<Ctx, _>("capture.stop", "x", |_, _, _| Ok(())));
1459        c.register(cmd::<Ctx, _>("capture.toggle", "x", |_, _, _| Ok(())));
1460        c.input.clone_from(&"capture.s".to_string());
1461
1462        c.tab_complete();
1463        assert_eq!(c.input, "capture.start");
1464        c.tab_complete();
1465        assert_eq!(c.input, "capture.stop");
1466        c.tab_complete();
1467        assert_eq!(c.input, "capture.start");
1468    }
1469
1470    #[test]
1471    fn binds_are_enumerated_in_key_order() {
1472        let mut c = Console::<Ctx>::new();
1473        c.bind(Key::F12, "third");
1474        c.bind(Key::Backtick, "first");
1475        c.bind(Key::F1, "second");
1476        let seen: Vec<(Key, &str)> = c.binds().collect();
1477        assert_eq!(
1478            seen,
1479            vec![
1480                (Key::Backtick, "first"),
1481                (Key::F1, "second"),
1482                (Key::F12, "third"),
1483            ]
1484        );
1485    }
1486
1487    #[test]
1488    fn command_returning_err_pushes_error_line() {
1489        let mut c = Console::<Ctx>::new();
1490        c.register(cmd("fail", "always fails", |_, _, _| anyhow::bail!("nope")));
1491        let mut ctx: Ctx = 0;
1492        run(&mut c, "fail", &mut ctx);
1493        let last = c.history.back().unwrap();
1494        assert_eq!(last.kind, LineKind::Error);
1495        assert!(last.text.contains("nope"));
1496    }
1497
1498    #[test]
1499    fn submit_runs_the_input_line_and_clears_it() {
1500        let mut c = Console::<Ctx>::new();
1501        c.register(add_cmd());
1502        let mut ctx: Ctx = 1;
1503        *c.input_mut() = "add 4".into();
1504        submit_and_run(&mut c, &mut ctx);
1505        assert_eq!(ctx, 5);
1506        assert!(c.input.is_empty());
1507    }
1508
1509    #[test]
1510    fn submit_of_blank_input_is_inert() {
1511        let mut c = Console::<Ctx>::new();
1512        let mut ctx: Ctx = 0;
1513        *c.input_mut() = "   ".into();
1514        submit_and_run(&mut c, &mut ctx);
1515        assert!(c.history.is_empty());
1516        assert!(c.input_history.is_empty());
1517    }
1518
1519    #[test]
1520    fn clear_input_drops_prompt_history_cursor_and_tab_cycle() {
1521        let mut c = Console::<Ctx>::new();
1522        c.register(cmd::<Ctx, _>("capture.start", "x", |_, _, _| Ok(())));
1523        c.register(cmd::<Ctx, _>("capture.stop", "x", |_, _, _| Ok(())));
1524        let mut ctx: Ctx = 0;
1525        run(&mut c, "capture.stop", &mut ctx);
1526        c.history_prev();
1527        c.input = "capture.s".into();
1528        c.tab_complete();
1529        assert!(c.tab.is_some());
1530
1531        c.clear_input();
1532        assert!(c.input.is_empty());
1533        assert!(c.tab.is_none());
1534        assert!(c.input_history_pos.is_none());
1535    }
1536
1537    #[test]
1538    fn clear_history_leaves_input_history_intact() {
1539        let mut c = Console::<Ctx>::new();
1540        c.register(echo_cmd());
1541        let mut ctx: Ctx = 0;
1542        run(&mut c, "echo a", &mut ctx);
1543        c.clear_history();
1544        assert!(c.history.is_empty());
1545        assert_eq!(c.input_history.len(), 1);
1546    }
1547
1548    type SubCtx = (u32, String);
1549
1550    fn sample_subset() -> SubcommandSet<SubCtx> {
1551        subcommands::<SubCtx>("tests", "umbrella")
1552            .toggle("axes", "toggle axes", |c, v| {
1553                let on = v.unwrap_or(c.0 != 1);
1554                c.0 = if on { 1 } else { 0 };
1555                c.1 = format!("axes={on}");
1556                Ok(())
1557            })
1558            .toggle("cube", "toggle cube", |c, v| {
1559                let on = v.unwrap_or(c.0 != 2);
1560                c.0 = if on { 2 } else { 0 };
1561                c.1 = format!("cube={on}");
1562                Ok(())
1563            })
1564            .choice(
1565                "polytope",
1566                "set polytope",
1567                &["5cell", "tesseract", "off"],
1568                |c, name| {
1569                    c.1 = format!("polytope={}", name.unwrap_or("<bare>"));
1570                    Ok(())
1571                },
1572            )
1573    }
1574
1575    #[test]
1576    fn subcommand_toggle_accepts_aliases() {
1577        let mut con = Console::<SubCtx>::new();
1578        con.register(sample_subset());
1579        let mut ctx: SubCtx = (0, String::new());
1580        for alias in &["on", "true", "1"] {
1581            run(&mut con, &format!("tests axes {alias}"), &mut ctx);
1582            assert_eq!(ctx.1, "axes=true", "alias `{alias}`");
1583        }
1584        for alias in &["off", "false", "0"] {
1585            run(&mut con, &format!("tests axes {alias}"), &mut ctx);
1586            assert_eq!(ctx.1, "axes=false", "alias `{alias}`");
1587        }
1588    }
1589
1590    #[test]
1591    fn subcommand_unknown_subcommand_errors() {
1592        let mut con = Console::<SubCtx>::new();
1593        con.register(sample_subset());
1594        let mut ctx: SubCtx = (0, String::new());
1595        run(&mut con, "tests xyzzy on", &mut ctx);
1596        let last = con.history.back().unwrap();
1597        assert_eq!(last.kind, LineKind::Error);
1598        assert!(
1599            last.text.contains("unknown subcommand"),
1600            "got: {}",
1601            last.text
1602        );
1603    }
1604
1605    #[test]
1606    fn subcommand_toggle_bare_invocation_flips() {
1607        let mut con = Console::<SubCtx>::new();
1608        con.register(sample_subset());
1609        let mut ctx: SubCtx = (0, String::new());
1610        run(&mut con, "tests axes", &mut ctx);
1611        assert_eq!(ctx, (1, "axes=true".into()));
1612        run(&mut con, "tests axes", &mut ctx);
1613        assert_eq!(ctx, (0, "axes=false".into()));
1614    }
1615
1616    #[test]
1617    fn subcommand_choice_bare_invocation_passes_none() {
1618        let mut con = Console::<SubCtx>::new();
1619        con.register(sample_subset());
1620        let mut ctx: SubCtx = (0, String::new());
1621        run(&mut con, "tests polytope", &mut ctx);
1622        assert_eq!(ctx.1, "polytope=<bare>");
1623    }
1624
1625    #[test]
1626    fn subcommand_bare_runs_on_bare_handler() {
1627        let mut con = Console::<SubCtx>::new();
1628        con.register(sample_subset().on_bare(|c| {
1629            c.1 = "bare!".into();
1630            Ok(())
1631        }));
1632        let mut ctx: SubCtx = (0, String::new());
1633        run(&mut con, "tests", &mut ctx);
1634        assert_eq!(ctx.1, "bare!");
1635    }
1636
1637    #[test]
1638    fn subcommand_bare_without_handler_emits_usage() {
1639        let mut con = Console::<SubCtx>::new();
1640        con.register(sample_subset());
1641        let mut ctx: SubCtx = (0, String::new());
1642        run(&mut con, "tests", &mut ctx);
1643        let last = con.history.back().unwrap();
1644        assert_eq!(last.kind, LineKind::Error);
1645        assert!(last.text.contains("subcommands"), "got: {}", last.text);
1646    }
1647
1648    #[test]
1649    fn subcommand_value_completion_is_context_aware() {
1650        let mut con = Console::<SubCtx>::new();
1651        con.register(sample_subset());
1652
1653        con.input = "tests axes ".into();
1654        let ctx = con.completion_context().unwrap();
1655        let m = con.completion_matches(&ctx);
1656        assert!(
1657            m.is_empty(),
1658            "toggle value slot should suggest nothing, got {m:?}"
1659        );
1660
1661        con.input = "tests polytope ".into();
1662        let ctx = con.completion_context().unwrap();
1663        let m = con.completion_matches(&ctx);
1664        assert_eq!(
1665            m,
1666            vec![
1667                "5cell".to_string(),
1668                "off".to_string(),
1669                "tesseract".to_string()
1670            ]
1671        );
1672        assert!(!m.contains(&"on".into()));
1673    }
1674
1675    type CustomCtx = Vec<String>;
1676
1677    fn custom_subset() -> SubcommandSet<CustomCtx> {
1678        subcommands::<CustomCtx>("capture", "umbrella")
1679            .custom("stop", "stop running capture", &[], &[], |c, rest, _out| {
1680                c.push(format!("stop;rest={}", rest.join(",")));
1681                Ok(())
1682            })
1683            .custom(
1684                "png",
1685                "one-shot png",
1686                &[&["pre", "post", "both"]],
1687                &[],
1688                |c, rest, _out| {
1689                    c.push(format!("png;rest={}", rest.join(",")));
1690                    Ok(())
1691                },
1692            )
1693            .custom(
1694                "gif",
1695                "gif sequence",
1696                &[
1697                    &["pre", "post", "both"],
1698                    &["fps=", "palette=", "scale="],
1699                    &["fps=", "palette=", "scale="],
1700                ],
1701                &[("palette", &["local", "global"])],
1702                |c, rest, _out| {
1703                    c.push(format!("gif;rest={}", rest.join(",")));
1704                    Ok(())
1705                },
1706            )
1707    }
1708
1709    #[test]
1710    fn custom_subcommand_dispatch_receives_full_rest() {
1711        let mut con = Console::<CustomCtx>::new();
1712        con.register(custom_subset());
1713        let mut ctx: CustomCtx = Vec::new();
1714
1715        run(&mut con, "capture png post", &mut ctx);
1716        run(&mut con, "capture gif both fps=30 palette=global", &mut ctx);
1717        run(&mut con, "capture stop", &mut ctx);
1718
1719        assert_eq!(
1720            ctx,
1721            vec![
1722                "png;rest=post".to_string(),
1723                "gif;rest=both,fps=30,palette=global".to_string(),
1724                "stop;rest=".to_string(),
1725            ]
1726        );
1727    }
1728
1729    #[test]
1730    fn custom_multi_slot_completion_per_slot() {
1731        let mut con = Console::<CustomCtx>::new();
1732        con.register(custom_subset());
1733
1734        con.input = "capture gif ".into();
1735        let ctx = con.completion_context().unwrap();
1736        let m = con.completion_matches(&ctx);
1737        assert_eq!(
1738            m,
1739            vec!["both".to_string(), "post".to_string(), "pre".to_string()]
1740        );
1741
1742        con.input = "capture gif post ".into();
1743        let ctx = con.completion_context().unwrap();
1744        let m = con.completion_matches(&ctx);
1745        assert!(m.contains(&"fps=".into()));
1746        assert!(m.contains(&"palette=".into()));
1747        assert!(m.contains(&"scale=".into()));
1748
1749        con.input = "capture png ".into();
1750        let ctx = con.completion_context().unwrap();
1751        let m = con.completion_matches(&ctx);
1752        assert!(m.contains(&"post".into()));
1753        assert!(!m.contains(&"fps=".into()), "got: {m:?}");
1754
1755        con.input = "capture stop ".into();
1756        let ctx = con.completion_context().unwrap();
1757        let m = con.completion_matches(&ctx);
1758        assert!(m.is_empty(), "got: {m:?}");
1759    }
1760
1761    #[test]
1762    fn custom_subcommand_kv_value_completion_is_context_aware() {
1763        let mut con = Console::<CustomCtx>::new();
1764        con.register(custom_subset());
1765
1766        con.input = "capture gif post palette=".into();
1767        let ctx = con.completion_context().unwrap();
1768        let m = con.completion_matches(&ctx);
1769        assert_eq!(
1770            m,
1771            vec!["palette=global".to_string(), "palette=local".to_string()]
1772        );
1773    }
1774
1775    #[test]
1776    fn tokenize_preserves_spaces_in_double_quotes() {
1777        assert_eq!(
1778            tokenize(r#"foo "bar baz" qux"#),
1779            vec!["foo", "bar baz", "qux"]
1780        );
1781    }
1782
1783    #[test]
1784    fn tokenize_handles_double_quote_escapes() {
1785        assert_eq!(
1786            tokenize(r#"a "he said \"hi\"" b"#),
1787            vec!["a", r#"he said "hi""#, "b"]
1788        );
1789        assert_eq!(tokenize(r#""back\\slash""#), vec![r"back\slash"]);
1790    }
1791
1792    #[test]
1793    fn tokenize_single_quotes_are_literal() {
1794        assert_eq!(tokenize(r"'a \n b'"), vec![r"a \n b"]);
1795    }
1796
1797    #[test]
1798    fn tokenize_unterminated_quote_consumes_to_end() {
1799        assert_eq!(
1800            tokenize(r#"foo "unterminated"#),
1801            vec!["foo", "unterminated"]
1802        );
1803    }
1804
1805    #[test]
1806    fn help_lists_user_commands_and_builtins_sorted() {
1807        type Ctx = u32;
1808        let mut con = Console::<Ctx>::new();
1809        con.register(cmd("zebra", "fast horse", |_, _, _| Ok(())));
1810        con.register(cmd("alpha", "first letter", |_, _, _| Ok(())));
1811        let mut ctx: Ctx = 0;
1812        run(&mut con, "help", &mut ctx);
1813        let texts: Vec<&str> = con.history.iter().map(|h| h.text.as_str()).collect();
1814        let i_alpha = texts.iter().position(|t| t.contains("alpha")).unwrap();
1815        let i_clear = texts.iter().position(|t| t.contains("clear")).unwrap();
1816        let i_zebra = texts.iter().position(|t| t.contains("zebra")).unwrap();
1817        assert!(i_alpha < i_clear);
1818        assert!(i_clear < i_zebra);
1819    }
1820
1821    #[test]
1822    fn builtins_run_on_the_typed_frame_and_registry_commands_wait() {
1823        let mut c = Console::<Ctx>::new();
1824        c.register(add_cmd());
1825        let mut ctx: Ctx = 0;
1826
1827        c.execute("add 5");
1828        c.execute("clear");
1829        assert!(c.history.is_empty(), "clear must act on the typed frame");
1830
1831        assert_eq!(
1832            c.drain_pending(),
1833            ["add 5"],
1834            "only the registry line queued"
1835        );
1836
1837        c.execute("add 5");
1838        drain_and_dispatch(&mut c, &mut ctx);
1839        assert_eq!(ctx, 5);
1840    }
1841
1842    #[test]
1843    fn a_builtin_does_the_same_thing_from_either_entry_point() {
1844        for line in ["clear", "detach", "dock", "help", "help echo"] {
1845            let mut typed = Console::<Ctx>::new();
1846            let mut queued = Console::<Ctx>::new();
1847            for console in [&mut typed, &mut queued] {
1848                console.register(echo_cmd());
1849                console.push_history(HistoryLine::output("earlier output"));
1850            }
1851
1852            typed.execute(line);
1853            let (name, args) = parse_line(line).expect("the fixture lines tokenize");
1854            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1855            queued.dispatch(&name, &arg_refs, &mut 0);
1856
1857            assert_eq!(typed.detached, queued.detached, "`{line}`");
1858            let rendered = |c: &Console<Ctx>| -> Vec<(LineKind, String)> {
1859                c.history.iter().map(|l| (l.kind, l.text.clone())).collect()
1860            };
1861            assert_eq!(rendered(&typed), rendered(&queued), "`{line}`");
1862            assert!(
1863                !queued.history.iter().any(|l| l.kind == LineKind::Error),
1864                "`{line}` reported as unregistered: {:?}",
1865                rendered(&queued)
1866            );
1867        }
1868    }
1869
1870    #[test]
1871    fn a_line_is_echoed_exactly_once_ahead_of_its_own_output() {
1872        let mut c = Console::<Ctx>::new();
1873        c.register(echo_cmd());
1874        let mut ctx: Ctx = 0;
1875
1876        c.execute("echo hi");
1877        assert!(
1878            c.history.is_empty(),
1879            "a parked line has not run, so nothing is owed to the scrollback yet"
1880        );
1881        drain_and_dispatch(&mut c, &mut ctx);
1882        let kinds: Vec<LineKind> = c.history.iter().map(|l| l.kind).collect();
1883        assert_eq!(kinds, [LineKind::Input, LineKind::Output]);
1884        assert!(c.history[0].text.contains("echo hi"), "{:?}", c.history[0]);
1885
1886        c.clear_history();
1887        c.dispatch("nonesuch", &[], &mut ctx);
1888        let kinds: Vec<LineKind> = c.history.iter().map(|l| l.kind).collect();
1889        assert_eq!(kinds, [LineKind::Input, LineKind::Error]);
1890    }
1891
1892    #[test]
1893    fn a_rendered_line_reparses_to_the_invocation_it_came_from() {
1894        let cases: [(&str, &[&str]); 7] = [
1895            ("clear", &[]),
1896            ("echo", &["a", "b"]),
1897            ("load", &["5 cell", "fast"]),
1898            ("mark", &["#ff8800"]),
1899            ("say", &[r#"a "quoted" word"#]),
1900            ("say", &["it's"]),
1901            ("say", &["", "back\\slash"]),
1902        ];
1903        for (name, args) in cases {
1904            let rendered = render_line(name, args);
1905            let (back_name, back_args) = parse_line(&rendered)
1906                .unwrap_or_else(|| panic!("`{rendered}` tokenizes to nothing"));
1907            let back_args: Vec<&str> = back_args.iter().map(String::as_str).collect();
1908            assert_eq!(back_name, name, "`{rendered}`");
1909            assert_eq!(back_args, args, "`{rendered}`");
1910        }
1911    }
1912
1913    #[test]
1914    fn submission_order_survives_the_pending_buffer() {
1915        let mut c = Console::<Ctx>::new();
1916        c.register(echo_cmd());
1917        for line in ["echo a", "echo b", "echo a", "echo c"] {
1918            c.execute(line);
1919        }
1920        assert_eq!(c.drain_pending(), ["echo a", "echo b", "echo a", "echo c"]);
1921        assert!(
1922            c.drain_pending().is_empty(),
1923            "a drained line must not be handed out twice"
1924        );
1925    }
1926}