1pub mod format;
13
14use std::borrow::Cow;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use anyhow::{Context, Result};
19use rustyline::completion::{Completer, FilenameCompleter, Pair};
20use rustyline::error::ReadlineError;
21use rustyline::highlight::Highlighter;
22use rustyline::hint::{Hint, Hinter};
23use rustyline::history::DefaultHistory;
24use rustyline::validate::{ValidationContext, ValidationResult, Validator};
25use rustyline::{Editor, Helper};
26use tokio::runtime::Runtime;
27
28use kaish_kernel::ast::Value;
29use kaish_kernel::interpreter::ExecResult;
30use kaish_kernel::{Kernel, KernelConfig};
31
32#[derive(Debug)]
36pub enum ProcessResult {
37 Output(String),
39 Empty,
41 Exit,
43}
44
45#[derive(Debug)]
47enum MetaResult {
48 Continue(Option<String>),
50 Exit,
52}
53
54struct KaishHelper {
58 kernel: Arc<Kernel>,
59 handle: tokio::runtime::Handle,
60 path_completer: FilenameCompleter,
61}
62
63impl KaishHelper {
64 fn new(kernel: Arc<Kernel>, handle: tokio::runtime::Handle) -> Self {
65 Self {
66 kernel,
67 handle,
68 path_completer: FilenameCompleter::new(),
69 }
70 }
71
72 fn is_incomplete(&self, input: &str) -> bool {
77 if input.trim_end().ends_with('\\') {
79 return true;
80 }
81
82 let mut depth: i32 = 0;
83 let mut in_single_quote = false;
84 let mut in_double_quote = false;
85
86 for line in input.lines() {
87 let mut chars = line.chars().peekable();
88
89 while let Some(ch) = chars.next() {
90 match ch {
91 '\\' if !in_single_quote => {
92 chars.next();
94 }
95 '\'' if !in_double_quote => {
96 in_single_quote = !in_single_quote;
97 }
98 '"' if !in_single_quote => {
99 in_double_quote = !in_double_quote;
100 }
101 _ => {}
102 }
103 }
104 }
105
106 if in_single_quote || in_double_quote {
108 return true;
109 }
110
111 for word in shell_words(input) {
113 match word.as_str() {
114 "if" | "for" | "while" | "case" => depth += 1,
115 "fi" | "done" | "esac" => depth -= 1,
116 "then" | "else" | "elif" => {
117 }
119 _ => {}
120 }
121 }
122
123 depth > 0
124 }
125}
126
127fn shell_words(input: &str) -> Vec<String> {
130 let mut words = Vec::new();
131 let mut current = String::new();
132 let mut in_single_quote = false;
133 let mut in_double_quote = false;
134 let mut in_comment = false;
135 let mut prev_was_backslash = false;
136
137 for ch in input.chars() {
138 if in_comment {
140 if ch == '\n' {
141 in_comment = false;
142 }
143 continue;
144 }
145
146 if prev_was_backslash {
147 prev_was_backslash = false;
148 if !in_single_quote {
149 current.push(ch);
150 continue;
151 }
152 }
153
154 match ch {
155 '\\' if !in_single_quote => {
156 prev_was_backslash = true;
157 }
158 '\'' if !in_double_quote => {
159 in_single_quote = !in_single_quote;
160 }
161 '"' if !in_single_quote => {
162 in_double_quote = !in_double_quote;
163 }
164 '#' if !in_single_quote && !in_double_quote => {
165 if !current.is_empty() {
166 words.push(std::mem::take(&mut current));
167 }
168 in_comment = true;
169 }
170 _ if ch.is_whitespace() && !in_single_quote && !in_double_quote => {
171 if !current.is_empty() {
172 words.push(std::mem::take(&mut current));
173 }
174 }
175 ';' if !in_single_quote && !in_double_quote => {
176 if !current.is_empty() {
178 words.push(std::mem::take(&mut current));
179 }
180 }
181 _ => {
182 current.push(ch);
183 }
184 }
185 }
186
187 if !current.is_empty() {
188 words.push(current);
189 }
190
191 words
192}
193
194enum CompletionContext {
198 Command,
200 Variable,
202 Path,
204}
205
206fn is_word_delimiter(c: char) -> bool {
208 c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
209}
210
211fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
213 let before = &line[..pos];
214
215 let bytes = before.as_bytes();
219 let mut i = pos;
220 while i > 0 {
221 i -= 1;
222 let b = bytes[i];
223 if b == b'$' {
224 if i + 1 < pos && bytes[i + 1] == b'(' {
226 break;
227 }
228 return CompletionContext::Variable;
229 }
230 if b == b'{' && i > 0 && bytes[i - 1] == b'$' {
231 return CompletionContext::Variable;
232 }
233 if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
235 break;
236 }
237 }
238
239 let trimmed = before.trim();
241 if trimmed.is_empty()
242 || trimmed.ends_with('|')
243 || trimmed.ends_with(';')
244 || trimmed.ends_with("&&")
245 || trimmed.ends_with("||")
246 || trimmed.ends_with("$(")
247 {
248 return CompletionContext::Command;
249 }
250
251 let word_start = before.rfind(is_word_delimiter);
253 match word_start {
254 None => CompletionContext::Command, Some(idx) => {
256 let prefix = before[..=idx].trim();
258 if prefix.is_empty()
259 || prefix.ends_with('|')
260 || prefix.ends_with(';')
261 || prefix.ends_with("&&")
262 || prefix.ends_with("||")
263 || prefix.ends_with("$(")
264 || prefix.ends_with("then")
265 || prefix.ends_with("else")
266 || prefix.ends_with("do")
267 {
268 CompletionContext::Command
269 } else {
270 CompletionContext::Path
271 }
272 }
273 }
274}
275
276impl Completer for KaishHelper {
279 type Candidate = Pair;
280
281 fn complete(
282 &self,
283 line: &str,
284 pos: usize,
285 ctx: &rustyline::Context<'_>,
286 ) -> rustyline::Result<(usize, Vec<Pair>)> {
287 match detect_completion_context(line, pos) {
288 CompletionContext::Command => {
289 let before = &line[..pos];
291 let word_start = before
292 .rfind(is_word_delimiter)
293 .map(|i| i + 1)
294 .unwrap_or(0);
295 let prefix = &line[word_start..pos];
296
297 let mut candidates = Vec::new();
298
299 for schema in self.kernel.tool_schemas() {
301 if schema.name.starts_with(prefix) {
302 candidates.push(Pair {
303 display: schema.name.clone(),
304 replacement: schema.name.clone(),
305 });
306 }
307 }
308
309 if prefix.starts_with('/') {
311 for cmd in META_COMMANDS {
312 if cmd.starts_with(prefix) {
313 candidates.push(Pair {
314 display: cmd.to_string(),
315 replacement: cmd.to_string(),
316 });
317 }
318 }
319 }
320
321 candidates.sort_by(|a, b| a.display.cmp(&b.display));
322
323 Ok((word_start, candidates))
324 }
325
326 CompletionContext::Variable => {
327 let before = &line[..pos];
329 let (var_start, prefix) = if let Some(brace_pos) = before.rfind("${") {
330 let name_start = brace_pos + 2;
331 (brace_pos, &line[name_start..pos])
332 } else if let Some(dollar_pos) = before.rfind('$') {
333 let name_start = dollar_pos + 1;
334 (dollar_pos, &line[name_start..pos])
335 } else {
336 return Ok((pos, vec![]));
337 };
338
339 let vars = self.handle.block_on(self.kernel.list_vars());
341
342 let mut candidates: Vec<Pair> = vars
343 .into_iter()
344 .filter(|(name, _)| name.starts_with(prefix))
345 .map(|(name, _)| {
346 let (display, replacement) = if before.contains("${") {
348 (name.clone(), format!("${{{name}}}"))
349 } else {
350 (name.clone(), format!("${name}"))
351 };
352 Pair {
353 display,
354 replacement,
355 }
356 })
357 .collect();
358
359 candidates.sort_by(|a, b| a.display.cmp(&b.display));
360
361 Ok((var_start, candidates))
362 }
363
364 CompletionContext::Path => self.path_completer.complete(line, pos, ctx),
365 }
366 }
367}
368
369impl Validator for KaishHelper {
370 fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
371 let input = ctx.input();
372 if input.trim().is_empty() {
373 return Ok(ValidationResult::Valid(None));
374 }
375 if self.is_incomplete(input) {
376 Ok(ValidationResult::Incomplete)
377 } else {
378 Ok(ValidationResult::Valid(None))
379 }
380 }
381}
382
383impl Highlighter for KaishHelper {
384 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
385 Cow::Borrowed(hint)
386 }
387}
388
389struct NoHint;
391impl Hint for NoHint {
392 fn display(&self) -> &str {
393 ""
394 }
395 fn completion(&self) -> Option<&str> {
396 None
397 }
398}
399
400impl Hinter for KaishHelper {
401 type Hint = NoHint;
402
403 fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
404 None
405 }
406}
407
408impl Helper for KaishHelper {}
409
410const META_COMMANDS: &[&str] = &[
414 "/help", "/quit", "/q", "/exit", "/ast", "/scope", "/vars", "/result",
415 "/cwd", "/tools", "/jobs", "/state", "/session", "/reset",
416];
417
418pub struct Repl {
422 kernel: Arc<Kernel>,
423 runtime: Runtime,
424 show_ast: bool,
425}
426
427impl Repl {
428 pub fn new() -> Result<Self> {
430 let config = KernelConfig::repl();
431 let kernel = Kernel::new(config).context("Failed to create kernel")?;
432 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
433
434 Ok(Self {
435 kernel: Arc::new(kernel),
436 runtime,
437 show_ast: false,
438 })
439 }
440
441 pub fn with_config(config: KernelConfig) -> Result<Self> {
443 let kernel = Kernel::new(config).context("Failed to create kernel")?;
444 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
445
446 Ok(Self {
447 kernel: Arc::new(kernel),
448 runtime,
449 show_ast: false,
450 })
451 }
452
453 pub fn with_root(root: PathBuf) -> Result<Self> {
455 let config = KernelConfig::repl().with_cwd(root);
456 Self::with_config(config)
457 }
458
459 pub fn process_line(&mut self, line: &str) -> ProcessResult {
461 let trimmed = line.trim();
462
463 if trimmed.starts_with('/') {
465 return match self.handle_meta_command(trimmed) {
466 MetaResult::Continue(Some(output)) => ProcessResult::Output(output),
467 MetaResult::Continue(None) => ProcessResult::Empty,
468 MetaResult::Exit => ProcessResult::Exit,
469 };
470 }
471
472 if let Some(meta_result) = self.try_shell_style_command(trimmed) {
474 return match meta_result {
475 MetaResult::Continue(Some(output)) => ProcessResult::Output(output),
476 MetaResult::Continue(None) => ProcessResult::Empty,
477 MetaResult::Exit => ProcessResult::Exit,
478 };
479 }
480
481 if trimmed.is_empty() {
483 return ProcessResult::Empty;
484 }
485
486 if self.show_ast {
488 match kaish_kernel::parser::parse(trimmed) {
489 Ok(program) => return ProcessResult::Output(format!("{:#?}", program)),
490 Err(errors) => {
491 let mut msg = String::from("Parse error:\n");
492 for err in errors {
493 msg.push_str(&format!(" {err}\n"));
494 }
495 return ProcessResult::Output(msg);
496 }
497 }
498 }
499
500 let result = self.runtime.block_on(self.kernel.execute(trimmed));
502
503 match result {
504 Ok(exec_result) => ProcessResult::Output(format_result(&exec_result)),
505 Err(e) => ProcessResult::Output(format!("Error: {}", e)),
506 }
507 }
508
509 fn handle_meta_command(&mut self, cmd: &str) -> MetaResult {
511 let parts: Vec<&str> = cmd.split_whitespace().collect();
512 let command = parts.first().copied().unwrap_or("");
513
514 match command {
515 "/quit" | "/q" | "/exit" => MetaResult::Exit,
516 "/help" | "/h" | "/?" => MetaResult::Continue(Some(HELP_TEXT.to_string())),
517 "/ast" => {
518 self.show_ast = !self.show_ast;
519 MetaResult::Continue(Some(format!(
520 "AST mode: {}",
521 if self.show_ast { "ON" } else { "OFF" }
522 )))
523 }
524 "/scope" | "/vars" => {
525 let vars = self.runtime.block_on(self.kernel.list_vars());
526 if vars.is_empty() {
527 MetaResult::Continue(Some("(no variables set)".to_string()))
528 } else {
529 let mut output = String::from("Variables:\n");
530 for (name, value) in vars {
531 output.push_str(&format!(" {} = {}\n", name, format_value(&value)));
532 }
533 MetaResult::Continue(Some(output.trim_end().to_string()))
534 }
535 }
536 "/result" | "/$?" => {
537 let result = self.runtime.block_on(self.kernel.last_result());
538 MetaResult::Continue(Some(format_result(&result)))
539 }
540 "/cwd" => {
541 let cwd = self.runtime.block_on(self.kernel.cwd());
542 MetaResult::Continue(Some(cwd.to_string_lossy().to_string()))
543 }
544 "/tools" => {
545 let schemas = self.kernel.tool_schemas();
546 let names: Vec<_> = schemas.iter().map(|s| s.name.as_str()).collect();
547 MetaResult::Continue(Some(format!("Available tools: {}", names.join(", "))))
548 }
549 "/jobs" => {
550 let jobs = self.runtime.block_on(self.kernel.jobs().list());
551 if jobs.is_empty() {
552 MetaResult::Continue(Some("(no background jobs)".to_string()))
553 } else {
554 let mut output = String::from("Background jobs:\n");
555 for job in jobs {
556 output.push_str(&format!(" [{}] {} {}\n", job.id, job.status, job.command));
557 }
558 MetaResult::Continue(Some(output.trim_end().to_string()))
559 }
560 }
561 "/state" | "/session" => {
562 let vars = self.runtime.block_on(self.kernel.list_vars());
563 MetaResult::Continue(Some(format!(
564 "Kernel: {}\nVariables: {}",
565 self.kernel.name(),
566 vars.len()
567 )))
568 }
569 "/clear-state" | "/reset" => {
570 if let Err(e) = self.runtime.block_on(self.kernel.reset()) {
571 MetaResult::Continue(Some(format!("Reset failed: {}", e)))
572 } else {
573 MetaResult::Continue(Some("Session reset (variables cleared)".to_string()))
574 }
575 }
576 _ => MetaResult::Continue(Some(format!(
577 "Unknown command: {}\nType /help or help for available commands.",
578 command
579 ))),
580 }
581 }
582
583 fn try_shell_style_command(&mut self, cmd: &str) -> Option<MetaResult> {
586 let parts: Vec<&str> = cmd.split_whitespace().collect();
587 let command = parts.first().copied().unwrap_or("");
588
589 match command {
590 "quit" | "exit" => Some(self.handle_meta_command("/quit")),
591 "help" => Some(self.handle_meta_command("/help")),
592 "reset" => Some(self.handle_meta_command("/reset")),
593 _ => None,
594 }
595 }
596}
597
598impl Default for Repl {
599 #[allow(clippy::expect_used)]
600 fn default() -> Self {
601 Self::new().expect("Failed to create REPL")
602 }
603}
604
605fn format_value(value: &Value) -> String {
609 match value {
610 Value::Null => "null".to_string(),
611 Value::Bool(b) => b.to_string(),
612 Value::Int(i) => i.to_string(),
613 Value::Float(f) => f.to_string(),
614 Value::String(s) => format!("\"{}\"", s),
615 Value::Json(json) => json.to_string(),
616 Value::Blob(blob) => format!("[blob: {} {}]", blob.formatted_size(), blob.content_type),
617 }
618}
619
620fn format_result(result: &ExecResult) -> String {
624 if result.output.is_some() {
626 let context = format::detect_context();
627 let formatted = format::format_output(result, context);
628
629 if !result.ok() && !result.err.is_empty() {
631 return format!("{}\n✗ code={} err=\"{}\"", formatted, result.code, result.err);
632 }
633 return formatted;
634 }
635
636 let status = if result.ok() { "✓" } else { "✗" };
638 let mut output = format!("{} code={}", status, result.code);
639
640 if !result.out.is_empty() {
641 if result.out.contains('\n') {
642 output.push_str(&format!("\n{}", result.out));
643 } else {
644 output.push_str(&format!(" out={}", result.out));
645 }
646 }
647
648 if !result.err.is_empty() {
649 output.push_str(&format!(" err=\"{}\"", result.err));
650 }
651
652 output
653}
654
655const HELP_TEXT: &str = r#"会sh — kaish REPL
658
659Meta Commands (use with or without /):
660 help, /help, /? Show this help
661 quit, /quit, /q Exit the REPL
662 reset, /reset Clear in-memory state
663
664Slash-only commands:
665 /ast Toggle AST display mode
666 /scope, /vars Show all variables (alt: `vars` builtin)
667 /result, /$? Show last command result
668 /cwd Show current working directory
669 /tools List available tools (alt: `tools` builtin)
670 /jobs List background jobs
671 /state, /session Show session info
672
673Built-in Tools:
674 echo [args...] Print arguments
675 cat <path> [-n] Read file contents (-n for line numbers)
676 ls [path] [-la] List directory (-a hidden, -l long)
677 cd [path | -] Change directory (- for previous)
678 pwd Print working directory
679 mkdir <path> Create directory
680 rm <path> [-rf] Remove file/directory
681 cp <src> <dst> [-r] Copy file/directory
682 mv <src> <dst> Move/rename
683 grep <pattern> [path] [-inv] Search patterns
684 write <path> <content> Write to file
685 date [format] Current date/time
686 assert <cond> Assert condition (for tests)
687 help [tool] Show tool help
688 jobs List background jobs
689 wait [job_id] Wait for background jobs
690
691External Commands:
692 Commands not found as builtins are searched in PATH
693 and executed as external processes (cargo, git, etc.)
694
695Language:
696 X=value Assign a variable
697 ${VAR} Variable reference
698 ${VAR.field} Nested access
699 ${?.ok} Last result access
700 a | b | c Pipeline (connects stdout → stdin)
701 cmd & Run in background
702 if cond; then ... fi
703 for X in arr; do ... done
704
705Multi-line:
706 Unclosed if/for/while blocks and quoted strings
707 automatically continue on the next line.
708
709Tab Completion:
710 <Tab> Complete commands, variables ($), or paths
711"#;
712
713fn save_history(rl: &mut Editor<KaishHelper, DefaultHistory>, history_path: &Option<PathBuf>) {
717 if let Some(path) = history_path {
718 if let Some(parent) = path.parent()
719 && let Err(e) = std::fs::create_dir_all(parent) {
720 tracing::warn!("Failed to create history directory: {}", e);
721 }
722 if let Err(e) = rl.save_history(path) {
723 tracing::warn!("Failed to save history: {}", e);
724 }
725 }
726}
727
728fn load_history(rl: &mut Editor<KaishHelper, DefaultHistory>) -> Option<PathBuf> {
730 let history_path = directories::BaseDirs::new()
731 .map(|b| b.data_dir().join("kaish").join("history.txt"));
732 if let Some(ref path) = history_path
733 && let Err(e) = rl.load_history(path) {
734 let is_not_found = matches!(&e, ReadlineError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound);
735 if !is_not_found {
736 tracing::warn!("Failed to load history: {}", e);
737 }
738 }
739 history_path
740}
741
742pub fn run() -> Result<()> {
746 println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
747 println!("Type /help for commands, /quit to exit.");
748
749 let mut repl = Repl::new()?;
750
751 let helper = KaishHelper::new(repl.kernel.clone(), repl.runtime.handle().clone());
753
754 let mut rl: Editor<KaishHelper, DefaultHistory> =
755 Editor::new().context("Failed to create editor")?;
756 rl.set_helper(Some(helper));
757
758 let history_path = load_history(&mut rl);
759
760 println!();
761
762 loop {
763 let prompt = "会sh> ";
764
765 match rl.readline(prompt) {
766 Ok(line) => {
767 if let Err(e) = rl.add_history_entry(line.as_str()) {
768 tracing::warn!("Failed to add history entry: {}", e);
769 }
770
771 match repl.process_line(&line) {
772 ProcessResult::Output(output) => println!("{}", output),
773 ProcessResult::Empty => {}
774 ProcessResult::Exit => {
775 save_history(&mut rl, &history_path);
776 return Ok(());
777 }
778 }
779 }
780 Err(ReadlineError::Interrupted) => {
781 println!("^C");
782 continue;
783 }
784 Err(ReadlineError::Eof) => {
785 println!("^D");
786 break;
787 }
788 Err(err) => {
789 eprintln!("Error: {}", err);
790 break;
791 }
792 }
793 }
794
795 save_history(&mut rl, &history_path);
796
797 Ok(())
798}
799
800pub fn run_with_client(
805 client: kaish_client::IpcClient,
806 rt: &Runtime,
807 local: &tokio::task::LocalSet,
808) -> Result<()> {
809 use kaish_client::KernelClient;
810
811 let simple_helper = SimpleHelper {
813 path_completer: FilenameCompleter::new(),
814 };
815
816 let mut rl: Editor<SimpleHelper, DefaultHistory> =
817 Editor::new().context("Failed to create editor")?;
818 rl.set_helper(Some(simple_helper));
819
820 let history_path = directories::BaseDirs::new()
821 .map(|b| b.data_dir().join("kaish").join("history.txt"));
822 if let Some(ref path) = history_path {
823 let _ = rl.load_history(path);
825 }
826
827 println!("会sh — kaish v{} (connected)", env!("CARGO_PKG_VERSION"));
828 println!("Type /help for commands, /quit to exit.");
829 println!();
830
831 loop {
832 let prompt = "会sh> ";
833
834 match rl.readline(prompt) {
835 Ok(line) => {
836 let _ = rl.add_history_entry(line.as_str());
837
838 let trimmed = line.trim();
839
840 match trimmed {
842 "/quit" | "/q" | "/exit" | "quit" | "exit" => break,
843 "/help" | "/?" | "help" => {
844 println!("{}", HELP_TEXT);
845 continue;
846 }
847 "" => continue,
848 _ => {}
849 }
850
851 let result = local.block_on(rt, async { client.execute(trimmed).await });
853
854 match result {
855 Ok(exec_result) => {
856 println!("{}", format_result(&exec_result));
857 }
858 Err(e) => {
859 eprintln!("Error: {}", e);
860 }
861 }
862 }
863 Err(ReadlineError::Interrupted) => {
864 println!("^C");
865 continue;
866 }
867 Err(ReadlineError::Eof) => {
868 println!("^D");
869 break;
870 }
871 Err(err) => {
872 eprintln!("Error: {}", err);
873 break;
874 }
875 }
876 }
877
878 if let Some(ref path) = history_path {
880 if let Some(parent) = path.parent()
881 && let Err(e) = std::fs::create_dir_all(parent) {
882 tracing::warn!("Failed to create history directory: {}", e);
883 }
884 if let Err(e) = rl.save_history(path) {
885 tracing::warn!("Failed to save history: {}", e);
886 }
887 }
888
889 Ok(())
890}
891
892struct SimpleHelper {
896 path_completer: FilenameCompleter,
897}
898
899impl Completer for SimpleHelper {
900 type Candidate = Pair;
901
902 fn complete(
903 &self,
904 line: &str,
905 pos: usize,
906 ctx: &rustyline::Context<'_>,
907 ) -> rustyline::Result<(usize, Vec<Pair>)> {
908 self.path_completer.complete(line, pos, ctx)
909 }
910}
911
912impl Highlighter for SimpleHelper {}
913impl Validator for SimpleHelper {}
914
915impl Hinter for SimpleHelper {
916 type Hint = NoHint;
917
918 fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
919 None
920 }
921}
922
923impl Helper for SimpleHelper {}
924
925#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[test]
932 fn test_shell_words_simple() {
933 assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
934 }
935
936 #[test]
937 fn test_shell_words_semicolons() {
938 assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
939 }
940
941 #[test]
942 fn test_shell_words_quoted() {
943 assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
945 }
946
947 #[test]
948 fn test_shell_words_single_quoted() {
949 assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
951 }
952
953 #[test]
954 fn test_is_incomplete_if_block() {
955 let helper = make_test_helper();
956 assert!(helper.is_incomplete("if true; then"));
957 assert!(helper.is_incomplete("if true; then\n echo hello"));
958 assert!(!helper.is_incomplete("if true; then\n echo hello\nfi"));
959 }
960
961 #[test]
962 fn test_is_incomplete_for_loop() {
963 let helper = make_test_helper();
964 assert!(helper.is_incomplete("for x in 1 2 3; do"));
965 assert!(!helper.is_incomplete("for x in 1 2 3; do\n echo $x\ndone"));
966 }
967
968 #[test]
969 fn test_is_incomplete_unclosed_single_quote() {
970 let helper = make_test_helper();
971 assert!(helper.is_incomplete("echo 'hello"));
972 assert!(!helper.is_incomplete("echo 'hello'"));
973 }
974
975 #[test]
976 fn test_is_incomplete_unclosed_double_quote() {
977 let helper = make_test_helper();
978 assert!(helper.is_incomplete("echo \"hello"));
979 assert!(!helper.is_incomplete("echo \"hello\""));
980 }
981
982 #[test]
983 fn test_is_incomplete_backslash_continuation() {
984 let helper = make_test_helper();
985 assert!(helper.is_incomplete("echo hello \\"));
986 assert!(!helper.is_incomplete("echo hello"));
987 }
988
989 #[test]
990 fn test_is_incomplete_while_loop() {
991 let helper = make_test_helper();
992 assert!(helper.is_incomplete("while true; do"));
993 assert!(!helper.is_incomplete("while true; do\n echo loop\ndone"));
994 }
995
996 #[test]
997 fn test_is_incomplete_nested() {
998 let helper = make_test_helper();
999 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do"));
1000 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done"));
1001 assert!(!helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done\nfi"));
1002 }
1003
1004 #[test]
1005 fn test_is_incomplete_empty() {
1006 let helper = make_test_helper();
1007 assert!(!helper.is_incomplete(""));
1008 assert!(!helper.is_incomplete("echo hello"));
1009 }
1010
1011 #[test]
1012 fn test_detect_context_command_start() {
1013 assert!(matches!(
1014 detect_completion_context("", 0),
1015 CompletionContext::Command
1016 ));
1017 assert!(matches!(
1018 detect_completion_context("ec", 2),
1019 CompletionContext::Command
1020 ));
1021 }
1022
1023 #[test]
1024 fn test_detect_context_after_pipe() {
1025 assert!(matches!(
1026 detect_completion_context("echo hello | gr", 15),
1027 CompletionContext::Command
1028 ));
1029 }
1030
1031 #[test]
1032 fn test_detect_context_variable() {
1033 assert!(matches!(
1034 detect_completion_context("echo $HO", 8),
1035 CompletionContext::Variable
1036 ));
1037 assert!(matches!(
1038 detect_completion_context("echo ${HO", 9),
1039 CompletionContext::Variable
1040 ));
1041 }
1042
1043 #[test]
1044 fn test_detect_context_path() {
1045 assert!(matches!(
1046 detect_completion_context("cat /etc/hos", 12),
1047 CompletionContext::Path
1048 ));
1049 }
1050
1051 #[test]
1052 fn test_detect_context_command_substitution() {
1053 assert!(matches!(
1055 detect_completion_context("echo $(ca", 9),
1056 CompletionContext::Command
1057 ));
1058 assert!(matches!(
1059 detect_completion_context("X=$(ec", 6),
1060 CompletionContext::Command
1061 ));
1062 }
1063
1064 #[test]
1065 fn test_shell_words_comments() {
1066 assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
1068 assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
1069 }
1070
1071 #[test]
1072 fn test_is_incomplete_comment_with_keyword() {
1073 let helper = make_test_helper();
1074 assert!(!helper.is_incomplete("# if this happens"));
1076 assert!(!helper.is_incomplete("echo hello # if we do this"));
1077 }
1078
1079 fn make_test_helper() -> KaishHelper {
1081 let config = KernelConfig::transient();
1082 let kernel = Kernel::new(config).expect("test kernel");
1083 let rt = Runtime::new().expect("test runtime");
1084 KaishHelper::new(Arc::new(kernel), rt.handle().clone())
1085 }
1086}