1pub mod format;
12
13use std::borrow::Cow;
14use std::io::IsTerminal;
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
32pub fn os_env_vars() -> std::collections::HashMap<String, Value> {
39 std::env::vars()
40 .map(|(k, v)| (k, Value::String(v)))
41 .collect()
42}
43
44#[derive(Debug)]
48pub enum ProcessResult {
49 Output(String),
51 Empty,
53 Exit,
55}
56
57struct KaishHelper {
61 kernel: Arc<Kernel>,
62 handle: tokio::runtime::Handle,
63 path_completer: FilenameCompleter,
64}
65
66impl KaishHelper {
67 fn new(kernel: Arc<Kernel>, handle: tokio::runtime::Handle) -> Self {
68 Self {
69 kernel,
70 handle,
71 path_completer: FilenameCompleter::new(),
72 }
73 }
74
75 fn is_incomplete(&self, input: &str) -> bool {
80 if input.trim_end().ends_with('\\') {
82 return true;
83 }
84
85 let mut depth: i32 = 0;
86 let mut in_single_quote = false;
87 let mut in_double_quote = false;
88
89 for line in input.lines() {
90 let mut chars = line.chars().peekable();
91
92 while let Some(ch) = chars.next() {
93 match ch {
94 '\\' if !in_single_quote => {
95 chars.next();
97 }
98 '\'' if !in_double_quote => {
99 in_single_quote = !in_single_quote;
100 }
101 '"' if !in_single_quote => {
102 in_double_quote = !in_double_quote;
103 }
104 _ => {}
105 }
106 }
107 }
108
109 if in_single_quote || in_double_quote {
111 return true;
112 }
113
114 for word in shell_words(input) {
116 match word.as_str() {
117 "if" | "for" | "while" | "case" => depth += 1,
118 "fi" | "done" | "esac" => depth -= 1,
119 "then" | "else" | "elif" => {
120 }
122 _ => {}
123 }
124 }
125
126 if depth > 0 {
127 return true;
128 }
129
130 if let Err(errs) = kaish_kernel::lexer::tokenize(input)
136 && errs
137 .iter()
138 .any(|e| matches!(e.token, kaish_kernel::lexer::LexerError::UnterminatedHeredoc { .. }))
139 {
140 return true;
141 }
142
143 false
144 }
145}
146
147fn shell_words(input: &str) -> Vec<String> {
150 let mut words = Vec::new();
151 let mut current = String::new();
152 let mut in_single_quote = false;
153 let mut in_double_quote = false;
154 let mut in_comment = false;
155 let mut prev_was_backslash = false;
156
157 for ch in input.chars() {
158 if in_comment {
160 if ch == '\n' {
161 in_comment = false;
162 }
163 continue;
164 }
165
166 if prev_was_backslash {
167 prev_was_backslash = false;
168 if !in_single_quote {
169 current.push(ch);
170 continue;
171 }
172 }
173
174 match ch {
175 '\\' if !in_single_quote => {
176 prev_was_backslash = true;
177 }
178 '\'' if !in_double_quote => {
179 in_single_quote = !in_single_quote;
180 }
181 '"' if !in_single_quote => {
182 in_double_quote = !in_double_quote;
183 }
184 '#' if !in_single_quote && !in_double_quote => {
185 if !current.is_empty() {
186 words.push(std::mem::take(&mut current));
187 }
188 in_comment = true;
189 }
190 _ if ch.is_whitespace() && !in_single_quote && !in_double_quote => {
191 if !current.is_empty() {
192 words.push(std::mem::take(&mut current));
193 }
194 }
195 ';' if !in_single_quote && !in_double_quote => {
196 if !current.is_empty() {
198 words.push(std::mem::take(&mut current));
199 }
200 }
201 _ => {
202 current.push(ch);
203 }
204 }
205 }
206
207 if !current.is_empty() {
208 words.push(current);
209 }
210
211 words
212}
213
214enum CompletionContext {
218 Command,
220 Variable,
222 Path,
224}
225
226fn is_word_delimiter(c: char) -> bool {
228 c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
229}
230
231fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
233 let before = &line[..pos];
234
235 let bytes = before.as_bytes();
239 let mut i = pos;
240 while i > 0 {
241 i -= 1;
242 let b = bytes[i];
243 if b == b'$' {
244 if i + 1 < pos && bytes[i + 1] == b'(' {
246 break;
247 }
248 return CompletionContext::Variable;
249 }
250 if b == b'{' && i > 0 && bytes[i - 1] == b'$' {
251 return CompletionContext::Variable;
252 }
253 if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
255 break;
256 }
257 }
258
259 let trimmed = before.trim();
261 if trimmed.is_empty()
262 || trimmed.ends_with('|')
263 || trimmed.ends_with(';')
264 || trimmed.ends_with("&&")
265 || trimmed.ends_with("||")
266 || trimmed.ends_with("$(")
267 {
268 return CompletionContext::Command;
269 }
270
271 let word_start = before.rfind(is_word_delimiter);
273 match word_start {
274 None => CompletionContext::Command, Some(idx) => {
276 let prefix = before[..=idx].trim();
278 if prefix.is_empty()
279 || prefix.ends_with('|')
280 || prefix.ends_with(';')
281 || prefix.ends_with("&&")
282 || prefix.ends_with("||")
283 || prefix.ends_with("$(")
284 || prefix.ends_with("then")
285 || prefix.ends_with("else")
286 || prefix.ends_with("do")
287 {
288 CompletionContext::Command
289 } else {
290 CompletionContext::Path
291 }
292 }
293 }
294}
295
296impl Completer for KaishHelper {
299 type Candidate = Pair;
300
301 fn complete(
302 &self,
303 line: &str,
304 pos: usize,
305 ctx: &rustyline::Context<'_>,
306 ) -> rustyline::Result<(usize, Vec<Pair>)> {
307 match detect_completion_context(line, pos) {
308 CompletionContext::Command => {
309 let before = &line[..pos];
311 let word_start = before
312 .rfind(is_word_delimiter)
313 .map(|i| i + 1)
314 .unwrap_or(0);
315 let prefix = &line[word_start..pos];
316
317 let mut candidates = Vec::new();
318
319 for schema in self.kernel.tool_schemas() {
321 if schema.name.starts_with(prefix) {
322 candidates.push(Pair {
323 display: schema.name.clone(),
324 replacement: schema.name.clone(),
325 });
326 }
327 }
328
329 candidates.sort_by(|a, b| a.display.cmp(&b.display));
330
331 Ok((word_start, candidates))
332 }
333
334 CompletionContext::Variable => {
335 let before = &line[..pos];
337 let (var_start, prefix) = if let Some(brace_pos) = before.rfind("${") {
338 let name_start = brace_pos + 2;
339 (brace_pos, &line[name_start..pos])
340 } else if let Some(dollar_pos) = before.rfind('$') {
341 let name_start = dollar_pos + 1;
342 (dollar_pos, &line[name_start..pos])
343 } else {
344 return Ok((pos, vec![]));
345 };
346
347 let vars = self.handle.block_on(self.kernel.list_vars());
349
350 let mut candidates: Vec<Pair> = vars
351 .into_iter()
352 .filter(|(name, _)| name.starts_with(prefix))
353 .map(|(name, _)| {
354 let (display, replacement) = if before.contains("${") {
356 (name.clone(), format!("${{{name}}}"))
357 } else {
358 (name.clone(), format!("${name}"))
359 };
360 Pair {
361 display,
362 replacement,
363 }
364 })
365 .collect();
366
367 candidates.sort_by(|a, b| a.display.cmp(&b.display));
368
369 Ok((var_start, candidates))
370 }
371
372 CompletionContext::Path => self.path_completer.complete(line, pos, ctx),
373 }
374 }
375}
376
377impl Validator for KaishHelper {
378 fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
379 let input = ctx.input();
380 if input.trim().is_empty() {
381 return Ok(ValidationResult::Valid(None));
382 }
383 if self.is_incomplete(input) {
384 Ok(ValidationResult::Incomplete)
385 } else {
386 Ok(ValidationResult::Valid(None))
387 }
388 }
389}
390
391impl Highlighter for KaishHelper {
392 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
393 Cow::Borrowed(hint)
394 }
395}
396
397struct NoHint;
399impl Hint for NoHint {
400 fn display(&self) -> &str {
401 ""
402 }
403 fn completion(&self) -> Option<&str> {
404 None
405 }
406}
407
408impl Hinter for KaishHelper {
409 type Hint = NoHint;
410
411 fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
412 None
413 }
414}
415
416impl Helper for KaishHelper {}
417
418pub struct Repl {
422 kernel: Arc<Kernel>,
423 runtime: Runtime,
424}
425
426impl Repl {
427 pub fn new() -> Result<Self> {
429 let config = KernelConfig::repl()
430 .with_interactive(true)
431 .with_initial_vars(os_env_vars());
432 let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
433 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
434
435 #[cfg(unix)]
437 if std::io::stdin().is_terminal() {
438 kernel.init_terminal();
439 }
440
441 Ok(Self {
442 kernel: kernel.into_arc(),
443 runtime,
444 })
445 }
446
447 pub fn with_config(config: KernelConfig) -> Result<Self> {
449 let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
450 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
451
452 #[cfg(unix)]
454 if std::io::stdin().is_terminal() {
455 kernel.init_terminal();
456 }
457
458 Ok(Self {
459 kernel: kernel.into_arc(),
460 runtime,
461 })
462 }
463
464 pub fn with_root(root: PathBuf) -> Result<Self> {
466 let config = KernelConfig::repl()
467 .with_cwd(root)
468 .with_initial_vars(os_env_vars());
469 Self::with_config(config)
470 }
471
472 pub fn process_line(&mut self, line: &str) -> ProcessResult {
474 let trimmed = line.trim();
475
476 if trimmed.is_empty() {
478 return ProcessResult::Empty;
479 }
480
481 if matches!(trimmed, "exit" | "quit") {
483 return ProcessResult::Exit;
484 }
485
486 let kernel = self.kernel.clone();
490 let input = trimmed.to_string();
491 let result = self.runtime.block_on(async {
492 let mut sigint = tokio::signal::unix::signal(
493 tokio::signal::unix::SignalKind::interrupt(),
494 )?;
495 tokio::select! {
496 result = kernel.execute(&input) => result,
497 _ = sigint.recv() => {
498 kernel.cancel();
499 Ok(ExecResult::failure(130, ""))
500 }
501 }
502 });
503
504 match result {
505 Ok(exec_result) => {
506 if exec_result.ok() && !exec_result.has_output() && exec_result.text_out().is_empty() {
507 ProcessResult::Empty
508 } else {
509 ProcessResult::Output(format_result(&exec_result))
510 }
511 }
512 Err(e) => ProcessResult::Output(format!("Error: {}", e)),
513 }
514 }
515}
516
517impl Default for Repl {
518 #[allow(clippy::expect_used)]
519 fn default() -> Self {
520 Self::new().expect("Failed to create REPL")
521 }
522}
523
524fn format_result(result: &ExecResult) -> String {
530 if result.has_output() {
532 let context = format::detect_context();
533 let formatted = format::format_output(result, context);
534
535 if !result.ok() && !result.err.is_empty() {
537 return format!("{}\n✗ code={} err=\"{}\"", formatted, result.code, result.err);
538 }
539 return formatted;
540 }
541
542 if result.ok() {
546 result.text_out().into_owned()
547 } else {
548 let mut output = String::new();
549 let text = result.text_out();
550 if !text.is_empty() {
551 output.push_str(&text);
552 if !output.ends_with('\n') {
553 output.push('\n');
554 }
555 }
556 if !result.err.is_empty() {
557 output.push_str(&format!("✗ {}", result.err));
558 } else {
559 output.push_str(&format!("✗ [exit {}]", result.code));
560 }
561 output
562 }
563}
564
565fn save_history(rl: &mut Editor<KaishHelper, DefaultHistory>, history_path: &Option<PathBuf>) {
569 if let Some(path) = history_path {
570 if let Some(parent) = path.parent()
571 && let Err(e) = std::fs::create_dir_all(parent) {
572 tracing::warn!("Failed to create history directory: {}", e);
573 }
574 if let Err(e) = rl.save_history(path) {
575 tracing::warn!("Failed to save history: {}", e);
576 }
577 }
578}
579
580fn load_history(rl: &mut Editor<KaishHelper, DefaultHistory>) -> Option<PathBuf> {
582 let history_path = directories::BaseDirs::new()
583 .map(|b| b.data_dir().join("kaish").join("history.txt"));
584 if let Some(ref path) = history_path
585 && let Err(e) = rl.load_history(path) {
586 let is_not_found = matches!(&e, ReadlineError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound);
587 if !is_not_found {
588 tracing::warn!("Failed to load history: {}", e);
589 }
590 }
591 history_path
592}
593
594fn load_rc_file(repl: &Repl) {
600 let candidates: Vec<PathBuf> = if let Ok(path) = std::env::var("KAISH_INIT") {
601 vec![PathBuf::from(path)]
602 } else {
603 vec![
604 kaish_kernel::paths::config_dir().join("init.kai"),
605 directories::BaseDirs::new()
606 .map(|b| b.home_dir().join(".kaishrc"))
607 .unwrap_or_else(|| PathBuf::from("/.kaishrc")),
608 ]
609 };
610
611 for path in &candidates {
612 if path.is_file() {
613 let cmd = format!(r#"source "{}""#, path.display());
614 if let Err(e) = repl.runtime.block_on(repl.kernel.execute(&cmd)) {
615 eprintln!("kaish: warning: error sourcing {}: {}", path.display(), e);
616 }
617 return;
618 }
619 }
620}
621
622fn resolve_prompt(repl: &Repl) -> String {
624 let has_fn = repl.runtime.block_on(repl.kernel.has_function("kaish_prompt"));
625 if has_fn {
626 if let Ok(result) = repl.runtime.block_on(repl.kernel.execute("kaish_prompt")) {
627 if result.ok() {
628 let text = result.text_out().trim_end().to_string();
629 if !text.is_empty() {
630 return text;
631 }
632 }
633 }
634 }
635 "会sh> ".to_string()
636}
637
638pub fn run() -> Result<()> {
642 println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
643 println!("Type help for commands, exit to quit.");
644
645 let mut repl = Repl::new()?;
646
647 load_rc_file(&repl);
649
650 let helper = KaishHelper::new(repl.kernel.clone(), repl.runtime.handle().clone());
652
653 let mut rl: Editor<KaishHelper, DefaultHistory> =
654 Editor::new().context("Failed to create editor")?;
655 rl.set_helper(Some(helper));
656
657 let history_path = load_history(&mut rl);
658
659 loop {
660 let prompt_string = resolve_prompt(&repl);
662 let prompt: &str = &prompt_string;
663
664 match rl.readline(prompt) {
665 Ok(line) => {
666 if let Err(e) = rl.add_history_entry(line.as_str()) {
667 tracing::warn!("Failed to add history entry: {}", e);
668 }
669
670 match repl.process_line(&line) {
671 ProcessResult::Output(output) => {
672 if output.ends_with('\n') {
673 print!("{}", output);
674 } else {
675 println!("{}", output);
676 }
677 }
678 ProcessResult::Empty => {}
679 ProcessResult::Exit => {
680 save_history(&mut rl, &history_path);
681 return Ok(());
682 }
683 }
684 }
685 Err(ReadlineError::Interrupted) => {
686 println!("^C");
687 continue;
688 }
689 Err(ReadlineError::Eof) => {
690 println!("^D");
691 break;
692 }
693 Err(err) => {
694 eprintln!("Error: {}", err);
695 break;
696 }
697 }
698 }
699
700 save_history(&mut rl, &history_path);
701
702 Ok(())
703}
704
705#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn test_shell_words_simple() {
713 assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
714 }
715
716 #[test]
717 fn test_shell_words_semicolons() {
718 assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
719 }
720
721 #[test]
722 fn test_shell_words_quoted() {
723 assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
725 }
726
727 #[test]
728 fn test_shell_words_single_quoted() {
729 assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
731 }
732
733 #[test]
734 fn test_is_incomplete_if_block() {
735 let helper = make_test_helper();
736 assert!(helper.is_incomplete("if true; then"));
737 assert!(helper.is_incomplete("if true; then\n echo hello"));
738 assert!(!helper.is_incomplete("if true; then\n echo hello\nfi"));
739 }
740
741 #[test]
742 fn test_is_incomplete_for_loop() {
743 let helper = make_test_helper();
744 assert!(helper.is_incomplete("for x in 1 2 3; do"));
745 assert!(!helper.is_incomplete("for x in 1 2 3; do\n echo $x\ndone"));
746 }
747
748 #[test]
749 fn test_is_incomplete_unclosed_single_quote() {
750 let helper = make_test_helper();
751 assert!(helper.is_incomplete("echo 'hello"));
752 assert!(!helper.is_incomplete("echo 'hello'"));
753 }
754
755 #[test]
756 fn test_is_incomplete_unclosed_double_quote() {
757 let helper = make_test_helper();
758 assert!(helper.is_incomplete("echo \"hello"));
759 assert!(!helper.is_incomplete("echo \"hello\""));
760 }
761
762 #[test]
763 fn test_is_incomplete_backslash_continuation() {
764 let helper = make_test_helper();
765 assert!(helper.is_incomplete("echo hello \\"));
766 assert!(!helper.is_incomplete("echo hello"));
767 }
768
769 #[test]
770 fn test_is_incomplete_while_loop() {
771 let helper = make_test_helper();
772 assert!(helper.is_incomplete("while true; do"));
773 assert!(!helper.is_incomplete("while true; do\n echo loop\ndone"));
774 }
775
776 #[test]
777 fn test_is_incomplete_nested() {
778 let helper = make_test_helper();
779 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do"));
780 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done"));
781 assert!(!helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done\nfi"));
782 }
783
784 #[test]
785 fn test_is_incomplete_empty() {
786 let helper = make_test_helper();
787 assert!(!helper.is_incomplete(""));
788 assert!(!helper.is_incomplete("echo hello"));
789 }
790
791 #[test]
792 fn test_is_incomplete_unterminated_heredoc() {
793 let helper = make_test_helper();
794 assert!(helper.is_incomplete("cat <<EOF"));
796 assert!(helper.is_incomplete("cat <<EOF\nhello"));
797 assert!(helper.is_incomplete("cat <<-DONE\n\thi"));
799 assert!(helper.is_incomplete("cat <<'EOF'\n$VAR"));
800 assert!(!helper.is_incomplete("cat <<EOF\nhello\nEOF"));
802 assert!(!helper.is_incomplete("cat <<-DONE\n\thi\n\tDONE"));
803 }
804
805 #[test]
806 fn test_detect_context_command_start() {
807 assert!(matches!(
808 detect_completion_context("", 0),
809 CompletionContext::Command
810 ));
811 assert!(matches!(
812 detect_completion_context("ec", 2),
813 CompletionContext::Command
814 ));
815 }
816
817 #[test]
818 fn test_detect_context_after_pipe() {
819 assert!(matches!(
820 detect_completion_context("echo hello | gr", 15),
821 CompletionContext::Command
822 ));
823 }
824
825 #[test]
826 fn test_detect_context_variable() {
827 assert!(matches!(
828 detect_completion_context("echo $HO", 8),
829 CompletionContext::Variable
830 ));
831 assert!(matches!(
832 detect_completion_context("echo ${HO", 9),
833 CompletionContext::Variable
834 ));
835 }
836
837 #[test]
838 fn test_detect_context_path() {
839 assert!(matches!(
840 detect_completion_context("cat /etc/hos", 12),
841 CompletionContext::Path
842 ));
843 }
844
845 #[test]
846 fn test_detect_context_command_substitution() {
847 assert!(matches!(
849 detect_completion_context("echo $(ca", 9),
850 CompletionContext::Command
851 ));
852 assert!(matches!(
853 detect_completion_context("X=$(ec", 6),
854 CompletionContext::Command
855 ));
856 }
857
858 #[test]
859 fn test_shell_words_comments() {
860 assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
862 assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
863 }
864
865 #[test]
866 fn test_is_incomplete_comment_with_keyword() {
867 let helper = make_test_helper();
868 assert!(!helper.is_incomplete("# if this happens"));
870 assert!(!helper.is_incomplete("echo hello # if we do this"));
871 }
872
873 fn make_test_helper() -> KaishHelper {
875 let config = KernelConfig::transient();
876 let kernel = Kernel::new(config).expect("test kernel").into_arc();
877 let rt = Runtime::new().expect("test runtime");
878 KaishHelper::new(kernel, rt.handle().clone())
879 }
880}