Skip to main content

kaish_repl/
lib.rs

1//! kaish REPL — Interactive shell for 会sh.
2//!
3//! This REPL provides an interactive interface to the kaish kernel.
4//! It handles:
5//! - Multi-line input via keyword/quote balancing (if/for/while → fi/done)
6//! - Tab completion for commands, variables, and paths
7//! - Command execution via the Kernel
8//! - Result formatting with OutputData
9//! - Command history via rustyline
10
11pub 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// ── Process result ──────────────────────────────────────────────────
32
33/// Result from processing a line of input.
34#[derive(Debug)]
35pub enum ProcessResult {
36    /// Output to display to the user.
37    Output(String),
38    /// No output (empty line, etc.).
39    Empty,
40    /// Exit the REPL.
41    Exit,
42}
43
44// ── KaishHelper ─────────────────────────────────────────────────────
45
46/// Rustyline helper providing validation, completion, highlighting, and hints.
47struct 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    /// Determine if the input is incomplete (needs more lines).
63    ///
64    /// Uses a heuristic approach: count keyword depth (if/for/while increment,
65    /// fi/done/esac decrement), check for unclosed quotes, and trailing backslash.
66    fn is_incomplete(&self, input: &str) -> bool {
67        // Trailing backslash = line continuation
68        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                        // Skip escaped character
83                        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        // Unclosed quotes
97        if in_single_quote || in_double_quote {
98            return true;
99        }
100
101        // Count keyword depth from words (outside quotes)
102        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                    // These don't change depth, they're part of an if block
108                }
109                _ => {}
110            }
111        }
112
113        depth > 0
114    }
115}
116
117/// Extract "words" from shell input, skipping quoted content.
118/// Only used for keyword counting — doesn't need to be a full tokenizer.
119fn 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        // Comments run to end of line
129        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                // Semicolons split words too (e.g. "if true; then")
167                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
184// ── Completion context ──────────────────────────────────────────────
185
186/// What kind of completion to offer based on cursor context.
187enum CompletionContext {
188    /// Start of line, after |, ;, &&, || → complete command names
189    Command,
190    /// After $ or within ${ → complete variable names
191    Variable,
192    /// Everything else → complete file paths
193    Path,
194}
195
196/// Characters that delimit words for completion purposes.
197fn is_word_delimiter(c: char) -> bool {
198    c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
199}
200
201/// Detect the completion context by scanning backwards from cursor position.
202fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
203    let before = &line[..pos];
204
205    // Check for variable completion: look for $ before cursor
206    // Walk backwards to find if we're in a $VAR or ${VAR context
207    // But NOT $( which is command substitution
208    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            // $( is command substitution, not variable
215            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        // Stop scanning if we hit a non-identifier character
224        if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
225            break;
226        }
227    }
228
229    // Check for command position: start of line, or after pipe/semicolon/logical operators/$(
230    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    // Find start of current "word" (using delimiters that include parentheses)
242    let word_start = before.rfind(is_word_delimiter);
243    match word_start {
244        None => CompletionContext::Command, // First word on the line
245        Some(idx) => {
246            // Check what's before the word
247            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
266// ── Rustyline trait impls ───────────────────────────────────────────
267
268impl 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                // Find the prefix being typed
280                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                // Tool/builtin names
290                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                // Find where the variable name starts (after $ or ${)
306                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                // list_vars is async, use block_on
318                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                        // Reconstruct the full $VAR or ${VAR} replacement
325                        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
367/// No-op hint type — we don't provide inline hints yet.
368struct 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
388// ── REPL core ───────────────────────────────────────────────────────
389
390/// REPL configuration and state.
391pub struct Repl {
392    kernel: Arc<Kernel>,
393    runtime: Runtime,
394}
395
396impl Repl {
397    /// Create a new REPL instance with passthrough filesystem access.
398    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        // Initialize terminal job control if stdin is a TTY
404        #[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    /// Create a new REPL with a custom kernel configuration.
416    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        // Initialize terminal job control if stdin is a TTY
421        #[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    /// Create a new REPL rooted at the given path.
433    pub fn with_root(root: PathBuf) -> Result<Self> {
434        let config = KernelConfig::repl().with_cwd(root);
435        Self::with_config(config)
436    }
437
438    /// Process a single line of input.
439    pub fn process_line(&mut self, line: &str) -> ProcessResult {
440        let trimmed = line.trim();
441
442        // Skip empty lines
443        if trimmed.is_empty() {
444            return ProcessResult::Empty;
445        }
446
447        // Intercept exit/quit before kernel dispatch
448        if matches!(trimmed, "exit" | "quit") {
449            return ProcessResult::Exit;
450        }
451
452        // Execute via kernel with SIGINT handling.
453        // A per-execute signal listener catches Ctrl-C during execution,
454        // cancels the kernel, and returns exit code 130.
455        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
490// ── Formatting ──────────────────────────────────────────────────────
491
492/// Format an ExecResult for display.
493///
494/// Uses OutputData when available, otherwise falls back to status+output format.
495fn format_result(result: &ExecResult) -> String {
496    // If there's structured output, use the formatter
497    if result.output.is_some() {
498        let context = format::detect_context();
499        let formatted = format::format_output(result, context);
500
501        // For failures, append error info
502        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    // No structured output — just pass through the raw text.
509    // Success: show output directly (no status prefix).
510    // Failure: show stderr or exit code so the user notices.
511    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
531// ── History ─────────────────────────────────────────────────────────
532
533/// Save REPL history to disk.
534fn 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
546/// Load REPL history from disk.
547fn 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
560// ── RC file and prompt ──────────────────────────────────────────────
561
562/// Load the RC file for interactive sessions.
563///
564/// Search order: `$KAISH_INIT` → `~/.config/kaish/init.kai` → `~/.kaishrc`
565fn 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
588/// Resolve the prompt string: call `kaish_prompt()` if defined, else default.
589fn 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
604// ── Entry points ────────────────────────────────────────────────────
605
606/// Run the REPL.
607pub 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    // Source RC file (interactive only)
614    load_rc_file(&repl);
615
616    // Build the helper with a kernel reference and runtime handle
617    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        // Dynamic prompt: call kaish_prompt() if defined, else default
627        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// ── Tests ───────────────────────────────────────────────────────────
672
673#[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        // Quoted content is a single word (spaces preserved inside)
690        assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
691    }
692
693    #[test]
694    fn test_shell_words_single_quoted() {
695        // Keywords inside quotes are not counted
696        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        // $(cmd should complete commands, not variables
800        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        // Keywords in comments should be ignored
813        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        // Comments containing keywords should NOT make input incomplete
821        assert!(!helper.is_incomplete("# if this happens"));
822        assert!(!helper.is_incomplete("echo hello # if we do this"));
823    }
824
825    /// Create a test helper (kernel is not used for is_incomplete).
826    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}