1mod 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
20pub 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 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 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 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 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 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 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 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 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 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 pub fn with_long_help(mut self, long: &'static str) -> Self {
338 self.long_help = Some(long);
339 self
340 }
341
342 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 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 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 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 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 #[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 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 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 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 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
1051pub 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
1062pub 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("e_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}