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