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::interpreter::ExecResult;
29use kaish_kernel::{Kernel, KernelConfig};
30
31#[derive(Debug)]
35pub enum ProcessResult {
36 Output(String),
38 Empty,
40 Exit,
42}
43
44struct KaishHelper {
48 kernel: Arc<Kernel>,
49 handle: tokio::runtime::Handle,
50 path_completer: FilenameCompleter,
51}
52
53impl KaishHelper {
54 fn new(kernel: Arc<Kernel>, handle: tokio::runtime::Handle) -> Self {
55 Self {
56 kernel,
57 handle,
58 path_completer: FilenameCompleter::new(),
59 }
60 }
61
62 fn is_incomplete(&self, input: &str) -> bool {
67 if input.trim_end().ends_with('\\') {
69 return true;
70 }
71
72 let mut depth: i32 = 0;
73 let mut in_single_quote = false;
74 let mut in_double_quote = false;
75
76 for line in input.lines() {
77 let mut chars = line.chars().peekable();
78
79 while let Some(ch) = chars.next() {
80 match ch {
81 '\\' if !in_single_quote => {
82 chars.next();
84 }
85 '\'' if !in_double_quote => {
86 in_single_quote = !in_single_quote;
87 }
88 '"' if !in_single_quote => {
89 in_double_quote = !in_double_quote;
90 }
91 _ => {}
92 }
93 }
94 }
95
96 if in_single_quote || in_double_quote {
98 return true;
99 }
100
101 for word in shell_words(input) {
103 match word.as_str() {
104 "if" | "for" | "while" | "case" => depth += 1,
105 "fi" | "done" | "esac" => depth -= 1,
106 "then" | "else" | "elif" => {
107 }
109 _ => {}
110 }
111 }
112
113 depth > 0
114 }
115}
116
117fn shell_words(input: &str) -> Vec<String> {
120 let mut words = Vec::new();
121 let mut current = String::new();
122 let mut in_single_quote = false;
123 let mut in_double_quote = false;
124 let mut in_comment = false;
125 let mut prev_was_backslash = false;
126
127 for ch in input.chars() {
128 if in_comment {
130 if ch == '\n' {
131 in_comment = false;
132 }
133 continue;
134 }
135
136 if prev_was_backslash {
137 prev_was_backslash = false;
138 if !in_single_quote {
139 current.push(ch);
140 continue;
141 }
142 }
143
144 match ch {
145 '\\' if !in_single_quote => {
146 prev_was_backslash = true;
147 }
148 '\'' if !in_double_quote => {
149 in_single_quote = !in_single_quote;
150 }
151 '"' if !in_single_quote => {
152 in_double_quote = !in_double_quote;
153 }
154 '#' if !in_single_quote && !in_double_quote => {
155 if !current.is_empty() {
156 words.push(std::mem::take(&mut current));
157 }
158 in_comment = true;
159 }
160 _ if ch.is_whitespace() && !in_single_quote && !in_double_quote => {
161 if !current.is_empty() {
162 words.push(std::mem::take(&mut current));
163 }
164 }
165 ';' if !in_single_quote && !in_double_quote => {
166 if !current.is_empty() {
168 words.push(std::mem::take(&mut current));
169 }
170 }
171 _ => {
172 current.push(ch);
173 }
174 }
175 }
176
177 if !current.is_empty() {
178 words.push(current);
179 }
180
181 words
182}
183
184enum CompletionContext {
188 Command,
190 Variable,
192 Path,
194}
195
196fn is_word_delimiter(c: char) -> bool {
198 c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
199}
200
201fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
203 let before = &line[..pos];
204
205 let bytes = before.as_bytes();
209 let mut i = pos;
210 while i > 0 {
211 i -= 1;
212 let b = bytes[i];
213 if b == b'$' {
214 if i + 1 < pos && bytes[i + 1] == b'(' {
216 break;
217 }
218 return CompletionContext::Variable;
219 }
220 if b == b'{' && i > 0 && bytes[i - 1] == b'$' {
221 return CompletionContext::Variable;
222 }
223 if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
225 break;
226 }
227 }
228
229 let trimmed = before.trim();
231 if trimmed.is_empty()
232 || trimmed.ends_with('|')
233 || trimmed.ends_with(';')
234 || trimmed.ends_with("&&")
235 || trimmed.ends_with("||")
236 || trimmed.ends_with("$(")
237 {
238 return CompletionContext::Command;
239 }
240
241 let word_start = before.rfind(is_word_delimiter);
243 match word_start {
244 None => CompletionContext::Command, Some(idx) => {
246 let prefix = before[..=idx].trim();
248 if prefix.is_empty()
249 || prefix.ends_with('|')
250 || prefix.ends_with(';')
251 || prefix.ends_with("&&")
252 || prefix.ends_with("||")
253 || prefix.ends_with("$(")
254 || prefix.ends_with("then")
255 || prefix.ends_with("else")
256 || prefix.ends_with("do")
257 {
258 CompletionContext::Command
259 } else {
260 CompletionContext::Path
261 }
262 }
263 }
264}
265
266impl Completer for KaishHelper {
269 type Candidate = Pair;
270
271 fn complete(
272 &self,
273 line: &str,
274 pos: usize,
275 ctx: &rustyline::Context<'_>,
276 ) -> rustyline::Result<(usize, Vec<Pair>)> {
277 match detect_completion_context(line, pos) {
278 CompletionContext::Command => {
279 let before = &line[..pos];
281 let word_start = before
282 .rfind(is_word_delimiter)
283 .map(|i| i + 1)
284 .unwrap_or(0);
285 let prefix = &line[word_start..pos];
286
287 let mut candidates = Vec::new();
288
289 for schema in self.kernel.tool_schemas() {
291 if schema.name.starts_with(prefix) {
292 candidates.push(Pair {
293 display: schema.name.clone(),
294 replacement: schema.name.clone(),
295 });
296 }
297 }
298
299 candidates.sort_by(|a, b| a.display.cmp(&b.display));
300
301 Ok((word_start, candidates))
302 }
303
304 CompletionContext::Variable => {
305 let before = &line[..pos];
307 let (var_start, prefix) = if let Some(brace_pos) = before.rfind("${") {
308 let name_start = brace_pos + 2;
309 (brace_pos, &line[name_start..pos])
310 } else if let Some(dollar_pos) = before.rfind('$') {
311 let name_start = dollar_pos + 1;
312 (dollar_pos, &line[name_start..pos])
313 } else {
314 return Ok((pos, vec![]));
315 };
316
317 let vars = self.handle.block_on(self.kernel.list_vars());
319
320 let mut candidates: Vec<Pair> = vars
321 .into_iter()
322 .filter(|(name, _)| name.starts_with(prefix))
323 .map(|(name, _)| {
324 let (display, replacement) = if before.contains("${") {
326 (name.clone(), format!("${{{name}}}"))
327 } else {
328 (name.clone(), format!("${name}"))
329 };
330 Pair {
331 display,
332 replacement,
333 }
334 })
335 .collect();
336
337 candidates.sort_by(|a, b| a.display.cmp(&b.display));
338
339 Ok((var_start, candidates))
340 }
341
342 CompletionContext::Path => self.path_completer.complete(line, pos, ctx),
343 }
344 }
345}
346
347impl Validator for KaishHelper {
348 fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
349 let input = ctx.input();
350 if input.trim().is_empty() {
351 return Ok(ValidationResult::Valid(None));
352 }
353 if self.is_incomplete(input) {
354 Ok(ValidationResult::Incomplete)
355 } else {
356 Ok(ValidationResult::Valid(None))
357 }
358 }
359}
360
361impl Highlighter for KaishHelper {
362 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
363 Cow::Borrowed(hint)
364 }
365}
366
367struct NoHint;
369impl Hint for NoHint {
370 fn display(&self) -> &str {
371 ""
372 }
373 fn completion(&self) -> Option<&str> {
374 None
375 }
376}
377
378impl Hinter for KaishHelper {
379 type Hint = NoHint;
380
381 fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
382 None
383 }
384}
385
386impl Helper for KaishHelper {}
387
388pub struct Repl {
392 kernel: Arc<Kernel>,
393 runtime: Runtime,
394}
395
396impl Repl {
397 pub fn new() -> Result<Self> {
399 let config = KernelConfig::repl().with_interactive(true);
400 let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
401 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
402
403 #[cfg(unix)]
405 if std::io::stdin().is_terminal() {
406 kernel.init_terminal();
407 }
408
409 Ok(Self {
410 kernel: Arc::new(kernel),
411 runtime,
412 })
413 }
414
415 pub fn with_config(config: KernelConfig) -> Result<Self> {
417 let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
418 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
419
420 #[cfg(unix)]
422 if std::io::stdin().is_terminal() {
423 kernel.init_terminal();
424 }
425
426 Ok(Self {
427 kernel: Arc::new(kernel),
428 runtime,
429 })
430 }
431
432 pub fn with_root(root: PathBuf) -> Result<Self> {
434 let config = KernelConfig::repl().with_cwd(root);
435 Self::with_config(config)
436 }
437
438 pub fn process_line(&mut self, line: &str) -> ProcessResult {
440 let trimmed = line.trim();
441
442 if trimmed.is_empty() {
444 return ProcessResult::Empty;
445 }
446
447 if matches!(trimmed, "exit" | "quit") {
449 return ProcessResult::Exit;
450 }
451
452 let kernel = self.kernel.clone();
456 let input = trimmed.to_string();
457 let result = self.runtime.block_on(async {
458 let mut sigint = tokio::signal::unix::signal(
459 tokio::signal::unix::SignalKind::interrupt(),
460 )?;
461 tokio::select! {
462 result = kernel.execute(&input) => result,
463 _ = sigint.recv() => {
464 kernel.cancel();
465 Ok(ExecResult::failure(130, ""))
466 }
467 }
468 });
469
470 match result {
471 Ok(exec_result) => {
472 if exec_result.ok() && exec_result.output.is_none() && exec_result.text_out().is_empty() {
473 ProcessResult::Empty
474 } else {
475 ProcessResult::Output(format_result(&exec_result))
476 }
477 }
478 Err(e) => ProcessResult::Output(format!("Error: {}", e)),
479 }
480 }
481}
482
483impl Default for Repl {
484 #[allow(clippy::expect_used)]
485 fn default() -> Self {
486 Self::new().expect("Failed to create REPL")
487 }
488}
489
490fn format_result(result: &ExecResult) -> String {
496 if result.output.is_some() {
498 let context = format::detect_context();
499 let formatted = format::format_output(result, context);
500
501 if !result.ok() && !result.err.is_empty() {
503 return format!("{}\n✗ code={} err=\"{}\"", formatted, result.code, result.err);
504 }
505 return formatted;
506 }
507
508 if result.ok() {
512 result.text_out().into_owned()
513 } else {
514 let mut output = String::new();
515 let text = result.text_out();
516 if !text.is_empty() {
517 output.push_str(&text);
518 if !output.ends_with('\n') {
519 output.push('\n');
520 }
521 }
522 if !result.err.is_empty() {
523 output.push_str(&format!("✗ {}", result.err));
524 } else {
525 output.push_str(&format!("✗ [exit {}]", result.code));
526 }
527 output
528 }
529}
530
531fn save_history(rl: &mut Editor<KaishHelper, DefaultHistory>, history_path: &Option<PathBuf>) {
535 if let Some(path) = history_path {
536 if let Some(parent) = path.parent()
537 && let Err(e) = std::fs::create_dir_all(parent) {
538 tracing::warn!("Failed to create history directory: {}", e);
539 }
540 if let Err(e) = rl.save_history(path) {
541 tracing::warn!("Failed to save history: {}", e);
542 }
543 }
544}
545
546fn load_history(rl: &mut Editor<KaishHelper, DefaultHistory>) -> Option<PathBuf> {
548 let history_path = directories::BaseDirs::new()
549 .map(|b| b.data_dir().join("kaish").join("history.txt"));
550 if let Some(ref path) = history_path
551 && let Err(e) = rl.load_history(path) {
552 let is_not_found = matches!(&e, ReadlineError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound);
553 if !is_not_found {
554 tracing::warn!("Failed to load history: {}", e);
555 }
556 }
557 history_path
558}
559
560fn load_rc_file(repl: &Repl) {
566 let candidates: Vec<PathBuf> = if let Ok(path) = std::env::var("KAISH_INIT") {
567 vec![PathBuf::from(path)]
568 } else {
569 vec![
570 kaish_kernel::paths::config_dir().join("init.kai"),
571 directories::BaseDirs::new()
572 .map(|b| b.home_dir().join(".kaishrc"))
573 .unwrap_or_else(|| PathBuf::from("/.kaishrc")),
574 ]
575 };
576
577 for path in &candidates {
578 if path.is_file() {
579 let cmd = format!(r#"source "{}""#, path.display());
580 if let Err(e) = repl.runtime.block_on(repl.kernel.execute(&cmd)) {
581 eprintln!("kaish: warning: error sourcing {}: {}", path.display(), e);
582 }
583 return;
584 }
585 }
586}
587
588fn resolve_prompt(repl: &Repl) -> String {
590 let has_fn = repl.runtime.block_on(repl.kernel.has_function("kaish_prompt"));
591 if has_fn {
592 if let Ok(result) = repl.runtime.block_on(repl.kernel.execute("kaish_prompt")) {
593 if result.ok() {
594 let text = result.text_out().trim_end().to_string();
595 if !text.is_empty() {
596 return text;
597 }
598 }
599 }
600 }
601 "会sh> ".to_string()
602}
603
604pub fn run() -> Result<()> {
608 println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
609 println!("Type help for commands, exit to quit.");
610
611 let mut repl = Repl::new()?;
612
613 load_rc_file(&repl);
615
616 let helper = KaishHelper::new(repl.kernel.clone(), repl.runtime.handle().clone());
618
619 let mut rl: Editor<KaishHelper, DefaultHistory> =
620 Editor::new().context("Failed to create editor")?;
621 rl.set_helper(Some(helper));
622
623 let history_path = load_history(&mut rl);
624
625 loop {
626 let prompt_string = resolve_prompt(&repl);
628 let prompt: &str = &prompt_string;
629
630 match rl.readline(prompt) {
631 Ok(line) => {
632 if let Err(e) = rl.add_history_entry(line.as_str()) {
633 tracing::warn!("Failed to add history entry: {}", e);
634 }
635
636 match repl.process_line(&line) {
637 ProcessResult::Output(output) => {
638 if output.ends_with('\n') {
639 print!("{}", output);
640 } else {
641 println!("{}", output);
642 }
643 }
644 ProcessResult::Empty => {}
645 ProcessResult::Exit => {
646 save_history(&mut rl, &history_path);
647 return Ok(());
648 }
649 }
650 }
651 Err(ReadlineError::Interrupted) => {
652 println!("^C");
653 continue;
654 }
655 Err(ReadlineError::Eof) => {
656 println!("^D");
657 break;
658 }
659 Err(err) => {
660 eprintln!("Error: {}", err);
661 break;
662 }
663 }
664 }
665
666 save_history(&mut rl, &history_path);
667
668 Ok(())
669}
670
671#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn test_shell_words_simple() {
679 assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
680 }
681
682 #[test]
683 fn test_shell_words_semicolons() {
684 assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
685 }
686
687 #[test]
688 fn test_shell_words_quoted() {
689 assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
691 }
692
693 #[test]
694 fn test_shell_words_single_quoted() {
695 assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
697 }
698
699 #[test]
700 fn test_is_incomplete_if_block() {
701 let helper = make_test_helper();
702 assert!(helper.is_incomplete("if true; then"));
703 assert!(helper.is_incomplete("if true; then\n echo hello"));
704 assert!(!helper.is_incomplete("if true; then\n echo hello\nfi"));
705 }
706
707 #[test]
708 fn test_is_incomplete_for_loop() {
709 let helper = make_test_helper();
710 assert!(helper.is_incomplete("for x in 1 2 3; do"));
711 assert!(!helper.is_incomplete("for x in 1 2 3; do\n echo $x\ndone"));
712 }
713
714 #[test]
715 fn test_is_incomplete_unclosed_single_quote() {
716 let helper = make_test_helper();
717 assert!(helper.is_incomplete("echo 'hello"));
718 assert!(!helper.is_incomplete("echo 'hello'"));
719 }
720
721 #[test]
722 fn test_is_incomplete_unclosed_double_quote() {
723 let helper = make_test_helper();
724 assert!(helper.is_incomplete("echo \"hello"));
725 assert!(!helper.is_incomplete("echo \"hello\""));
726 }
727
728 #[test]
729 fn test_is_incomplete_backslash_continuation() {
730 let helper = make_test_helper();
731 assert!(helper.is_incomplete("echo hello \\"));
732 assert!(!helper.is_incomplete("echo hello"));
733 }
734
735 #[test]
736 fn test_is_incomplete_while_loop() {
737 let helper = make_test_helper();
738 assert!(helper.is_incomplete("while true; do"));
739 assert!(!helper.is_incomplete("while true; do\n echo loop\ndone"));
740 }
741
742 #[test]
743 fn test_is_incomplete_nested() {
744 let helper = make_test_helper();
745 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do"));
746 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done"));
747 assert!(!helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done\nfi"));
748 }
749
750 #[test]
751 fn test_is_incomplete_empty() {
752 let helper = make_test_helper();
753 assert!(!helper.is_incomplete(""));
754 assert!(!helper.is_incomplete("echo hello"));
755 }
756
757 #[test]
758 fn test_detect_context_command_start() {
759 assert!(matches!(
760 detect_completion_context("", 0),
761 CompletionContext::Command
762 ));
763 assert!(matches!(
764 detect_completion_context("ec", 2),
765 CompletionContext::Command
766 ));
767 }
768
769 #[test]
770 fn test_detect_context_after_pipe() {
771 assert!(matches!(
772 detect_completion_context("echo hello | gr", 15),
773 CompletionContext::Command
774 ));
775 }
776
777 #[test]
778 fn test_detect_context_variable() {
779 assert!(matches!(
780 detect_completion_context("echo $HO", 8),
781 CompletionContext::Variable
782 ));
783 assert!(matches!(
784 detect_completion_context("echo ${HO", 9),
785 CompletionContext::Variable
786 ));
787 }
788
789 #[test]
790 fn test_detect_context_path() {
791 assert!(matches!(
792 detect_completion_context("cat /etc/hos", 12),
793 CompletionContext::Path
794 ));
795 }
796
797 #[test]
798 fn test_detect_context_command_substitution() {
799 assert!(matches!(
801 detect_completion_context("echo $(ca", 9),
802 CompletionContext::Command
803 ));
804 assert!(matches!(
805 detect_completion_context("X=$(ec", 6),
806 CompletionContext::Command
807 ));
808 }
809
810 #[test]
811 fn test_shell_words_comments() {
812 assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
814 assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
815 }
816
817 #[test]
818 fn test_is_incomplete_comment_with_keyword() {
819 let helper = make_test_helper();
820 assert!(!helper.is_incomplete("# if this happens"));
822 assert!(!helper.is_incomplete("echo hello # if we do this"));
823 }
824
825 fn make_test_helper() -> KaishHelper {
827 let config = KernelConfig::transient();
828 let kernel = Kernel::new(config).expect("test kernel");
829 let rt = Runtime::new().expect("test runtime");
830 KaishHelper::new(Arc::new(kernel), rt.handle().clone())
831 }
832}