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::ast::Value;
29use kaish_kernel::interpreter::ExecResult;
30use kaish_kernel::{Kernel, KernelConfig};
31
32/// Snapshot the OS environment as a map of `String` → `Value::String`.
33///
34/// The kernel itself is hermetic — it never reads `std::env::vars()`. The REPL
35/// (and other shell-like frontends) call this and pass the result via
36/// `KernelConfig::with_initial_vars` so users get their normal `PATH`, `HOME`,
37/// `EDITOR`, etc. propagated to subprocesses.
38pub 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// ── Process result ──────────────────────────────────────────────────
45
46/// Result from processing a line of input.
47#[derive(Debug)]
48pub enum ProcessResult {
49    /// Output to display to the user.
50    Output(String),
51    /// No output (empty line, etc.).
52    Empty,
53    /// Exit the REPL.
54    Exit,
55}
56
57// ── KaishHelper ─────────────────────────────────────────────────────
58
59/// Rustyline helper providing validation, completion, highlighting, and hints.
60struct 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    /// Determine if the input is incomplete (needs more lines).
76    ///
77    /// Uses a heuristic approach: count keyword depth (if/for/while increment,
78    /// fi/done/esac decrement), check for unclosed quotes, and trailing backslash.
79    fn is_incomplete(&self, input: &str) -> bool {
80        // Trailing backslash = line continuation
81        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                        // Skip escaped character
96                        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        // Unclosed quotes
110        if in_single_quote || in_double_quote {
111            return true;
112        }
113
114        // Count keyword depth from words (outside quotes)
115        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                    // These don't change depth, they're part of an if block
121                }
122                _ => {}
123            }
124        }
125
126        if depth > 0 {
127            return true;
128        }
129
130        // Heredoc continuation: ask the lexer whether any heredoc started
131        // without seeing its closing delimiter line. Single source of truth
132        // with the parser — no parallel hand-rolled heredoc scanner.
133        // Other lexer errors (invalid token, etc.) aren't continuation
134        // signals; let the kernel surface them on submit.
135        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
147/// Extract "words" from shell input, skipping quoted content.
148/// Only used for keyword counting — doesn't need to be a full tokenizer.
149fn 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        // Comments run to end of line
159        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                // Semicolons split words too (e.g. "if true; then")
197                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
214// ── Completion context ──────────────────────────────────────────────
215
216/// What kind of completion to offer based on cursor context.
217enum CompletionContext {
218    /// Start of line, after |, ;, &&, || → complete command names
219    Command,
220    /// After $ or within ${ → complete variable names
221    Variable,
222    /// Everything else → complete file paths
223    Path,
224}
225
226/// Characters that delimit words for completion purposes.
227fn is_word_delimiter(c: char) -> bool {
228    c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
229}
230
231/// Detect the completion context by scanning backwards from cursor position.
232fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
233    let before = &line[..pos];
234
235    // Check for variable completion: look for $ before cursor
236    // Walk backwards to find if we're in a $VAR or ${VAR context
237    // But NOT $( which is command substitution
238    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            // $( is command substitution, not variable
245            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        // Stop scanning if we hit a non-identifier character
254        if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
255            break;
256        }
257    }
258
259    // Check for command position: start of line, or after pipe/semicolon/logical operators/$(
260    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    // Find start of current "word" (using delimiters that include parentheses)
272    let word_start = before.rfind(is_word_delimiter);
273    match word_start {
274        None => CompletionContext::Command, // First word on the line
275        Some(idx) => {
276            // Check what's before the word
277            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
296// ── Rustyline trait impls ───────────────────────────────────────────
297
298impl 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                // Find the prefix being typed
310                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                // Tool/builtin names
320                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                // Find where the variable name starts (after $ or ${)
336                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                // list_vars is async, use block_on
348                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                        // Reconstruct the full $VAR or ${VAR} replacement
355                        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
397/// No-op hint type — we don't provide inline hints yet.
398struct 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
418// ── REPL core ───────────────────────────────────────────────────────
419
420/// REPL configuration and state.
421pub struct Repl {
422    kernel: Arc<Kernel>,
423    runtime: Runtime,
424}
425
426impl Repl {
427    /// Create a new REPL instance with passthrough filesystem access.
428    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        // Initialize terminal job control if stdin is a TTY
436        #[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    /// Create a new REPL with a custom kernel configuration.
448    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        // Initialize terminal job control if stdin is a TTY
453        #[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    /// Create a new REPL rooted at the given path.
465    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    /// Process a single line of input.
473    pub fn process_line(&mut self, line: &str) -> ProcessResult {
474        let trimmed = line.trim();
475
476        // Skip empty lines
477        if trimmed.is_empty() {
478            return ProcessResult::Empty;
479        }
480
481        // Intercept exit/quit before kernel dispatch
482        if matches!(trimmed, "exit" | "quit") {
483            return ProcessResult::Exit;
484        }
485
486        // Execute via kernel with SIGINT handling.
487        // A per-execute signal listener catches Ctrl-C during execution,
488        // cancels the kernel, and returns exit code 130.
489        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
524// ── Formatting ──────────────────────────────────────────────────────
525
526/// Format an ExecResult for display.
527///
528/// Uses OutputData when available, otherwise falls back to status+output format.
529fn format_result(result: &ExecResult) -> String {
530    // If there's structured output, use the formatter
531    if result.has_output() {
532        let context = format::detect_context();
533        let formatted = format::format_output(result, context);
534
535        // For failures, append error info
536        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    // No structured output — just pass through the raw text.
543    // Success: show output directly (no status prefix).
544    // Failure: show stderr or exit code so the user notices.
545    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
565// ── History ─────────────────────────────────────────────────────────
566
567/// Save REPL history to disk.
568fn 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
580/// Load REPL history from disk.
581fn 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
594// ── RC file and prompt ──────────────────────────────────────────────
595
596/// Load the RC file for interactive sessions.
597///
598/// Search order: `$KAISH_INIT` → `~/.config/kaish/init.kai` → `~/.kaishrc`
599fn 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
622/// Resolve the prompt string: call `kaish_prompt()` if defined, else default.
623fn 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
638// ── Entry points ────────────────────────────────────────────────────
639
640/// Run the REPL.
641pub 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    // Source RC file (interactive only)
648    load_rc_file(&repl);
649
650    // Build the helper with a kernel reference and runtime handle
651    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        // Dynamic prompt: call kaish_prompt() if defined, else default
661        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// ── Tests ───────────────────────────────────────────────────────────
706
707#[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        // Quoted content is a single word (spaces preserved inside)
724        assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
725    }
726
727    #[test]
728    fn test_shell_words_single_quoted() {
729        // Keywords inside quotes are not counted
730        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        // No closing EOF — REPL should prompt for more input.
795        assert!(helper.is_incomplete("cat <<EOF"));
796        assert!(helper.is_incomplete("cat <<EOF\nhello"));
797        // <<-form (tab-strip) and quoted delimiters too.
798        assert!(helper.is_incomplete("cat <<-DONE\n\thi"));
799        assert!(helper.is_incomplete("cat <<'EOF'\n$VAR"));
800        // Closing delimiter on its own line — complete.
801        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        // $(cmd should complete commands, not variables
848        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        // Keywords in comments should be ignored
861        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        // Comments containing keywords should NOT make input incomplete
869        assert!(!helper.is_incomplete("# if this happens"));
870        assert!(!helper.is_incomplete("echo hello # if we do this"));
871    }
872
873    /// Create a test helper (kernel is not used for is_incomplete).
874    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}