1pub mod format;
12
13use std::borrow::Cow;
14use std::io::IsTerminal;
15use std::path::PathBuf;
16
17use anyhow::{Context, Result};
18use rustyline::completion::{Completer, FilenameCompleter, Pair};
19use rustyline::error::ReadlineError;
20use rustyline::highlight::Highlighter;
21use rustyline::hint::{Hint, Hinter};
22use rustyline::history::DefaultHistory;
23use rustyline::validate::{ValidationContext, ValidationResult, Validator};
24use rustyline::{Editor, Helper};
25use tokio::runtime::Runtime;
26
27use kaish_client::{EmbeddedClient, KernelClient};
28use kaish_kernel::ast::Value;
29use kaish_kernel::interpreter::ExecResult;
30use kaish_kernel::{ExecuteOptions, Kernel, KernelConfig};
31
32pub fn os_env_vars() -> std::collections::HashMap<String, Value> {
39 std::env::vars()
40 .map(|(k, v)| (k, Value::String(v)))
41 .collect()
42}
43
44pub fn trace_options_from_env() -> ExecuteOptions {
54 parse_trace_env(|key| std::env::var(key).ok())
55}
56
57fn parse_trace_env(get: impl Fn(&str) -> Option<String>) -> ExecuteOptions {
60 let mut opts = ExecuteOptions::new();
61
62 if let Some(traceparent) = get("TRACEPARENT").filter(|s| !s.is_empty()) {
65 opts = opts.with_traceparent(traceparent);
66 if let Some(tracestate) = get("TRACESTATE").filter(|s| !s.is_empty()) {
67 opts = opts.with_tracestate(tracestate);
68 }
69 }
70
71 if let Some(raw) = get("BAGGAGE").filter(|s| !s.is_empty()) {
72 let baggage = parse_w3c_baggage(&raw);
73 if !baggage.is_empty() {
74 opts = opts.with_baggage(baggage);
75 }
76 }
77
78 opts
79}
80
81fn parse_w3c_baggage(raw: &str) -> std::collections::BTreeMap<String, String> {
89 let mut map = std::collections::BTreeMap::new();
90 for entry in raw.split(',') {
91 let entry = entry.trim();
92 if entry.is_empty() {
93 continue;
94 }
95 let Some((key, value)) = entry.split_once('=') else {
96 tracing::debug!(entry, "skipping malformed BAGGAGE entry (no '=')");
97 continue;
98 };
99 let value = value.split(';').next().unwrap_or(value);
100 map.insert(key.trim().to_string(), value.trim().to_string());
101 }
102 map
103}
104
105#[derive(Debug)]
109pub enum ProcessResult {
110 Output(String),
112 Empty,
114 Exit,
116}
117
118struct KaishHelper {
126 client: Box<dyn KernelClient>,
127 handle: tokio::runtime::Handle,
128 path_completer: FilenameCompleter,
129}
130
131impl KaishHelper {
132 fn new(client: Box<dyn KernelClient>, handle: tokio::runtime::Handle) -> Self {
133 Self {
134 client,
135 handle,
136 path_completer: FilenameCompleter::new(),
137 }
138 }
139
140 fn is_incomplete(&self, input: &str) -> bool {
145 if input.trim_end().ends_with('\\') {
147 return true;
148 }
149
150 let mut depth: i32 = 0;
151 let mut in_single_quote = false;
152 let mut in_double_quote = false;
153
154 for line in input.lines() {
155 let mut chars = line.chars().peekable();
156
157 while let Some(ch) = chars.next() {
158 match ch {
159 '\\' if !in_single_quote => {
160 chars.next();
162 }
163 '\'' if !in_double_quote => {
164 in_single_quote = !in_single_quote;
165 }
166 '"' if !in_single_quote => {
167 in_double_quote = !in_double_quote;
168 }
169 _ => {}
170 }
171 }
172 }
173
174 if in_single_quote || in_double_quote {
176 return true;
177 }
178
179 for word in shell_words(input) {
181 match word.as_str() {
182 "if" | "for" | "while" | "case" => depth += 1,
183 "fi" | "done" | "esac" => depth -= 1,
184 "then" | "else" | "elif" => {
185 }
187 _ => {}
188 }
189 }
190
191 if depth > 0 {
192 return true;
193 }
194
195 if let Err(errs) = kaish_kernel::lexer::tokenize(input)
201 && errs
202 .iter()
203 .any(|e| matches!(e.token, kaish_kernel::lexer::LexerError::UnterminatedHeredoc { .. }))
204 {
205 return true;
206 }
207
208 false
209 }
210}
211
212fn shell_words(input: &str) -> Vec<String> {
215 let mut words = Vec::new();
216 let mut current = String::new();
217 let mut in_single_quote = false;
218 let mut in_double_quote = false;
219 let mut in_comment = false;
220 let mut prev_was_backslash = false;
221
222 for ch in input.chars() {
223 if in_comment {
225 if ch == '\n' {
226 in_comment = false;
227 }
228 continue;
229 }
230
231 if prev_was_backslash {
232 prev_was_backslash = false;
233 if !in_single_quote {
234 current.push(ch);
235 continue;
236 }
237 }
238
239 match ch {
240 '\\' if !in_single_quote => {
241 prev_was_backslash = true;
242 }
243 '\'' if !in_double_quote => {
244 in_single_quote = !in_single_quote;
245 }
246 '"' if !in_single_quote => {
247 in_double_quote = !in_double_quote;
248 }
249 '#' if !in_single_quote && !in_double_quote => {
250 if !current.is_empty() {
251 words.push(std::mem::take(&mut current));
252 }
253 in_comment = true;
254 }
255 _ if ch.is_whitespace() && !in_single_quote && !in_double_quote => {
256 if !current.is_empty() {
257 words.push(std::mem::take(&mut current));
258 }
259 }
260 ';' if !in_single_quote && !in_double_quote => {
261 if !current.is_empty() {
263 words.push(std::mem::take(&mut current));
264 }
265 }
266 _ => {
267 current.push(ch);
268 }
269 }
270 }
271
272 if !current.is_empty() {
273 words.push(current);
274 }
275
276 words
277}
278
279enum CompletionContext {
283 Command,
285 Variable,
287 Path,
289}
290
291fn is_word_delimiter(c: char) -> bool {
293 c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
294}
295
296fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
298 let before = &line[..pos];
299
300 let bytes = before.as_bytes();
304 let mut i = pos;
305 while i > 0 {
306 i -= 1;
307 let b = bytes[i];
308 if b == b'$' {
309 if i + 1 < pos && bytes[i + 1] == b'(' {
311 break;
312 }
313 return CompletionContext::Variable;
314 }
315 if b == b'{' && i > 0 && bytes[i - 1] == b'$' {
316 return CompletionContext::Variable;
317 }
318 if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
320 break;
321 }
322 }
323
324 let trimmed = before.trim();
326 if trimmed.is_empty()
327 || trimmed.ends_with('|')
328 || trimmed.ends_with(';')
329 || trimmed.ends_with("&&")
330 || trimmed.ends_with("||")
331 || trimmed.ends_with("$(")
332 {
333 return CompletionContext::Command;
334 }
335
336 let word_start = before.rfind(is_word_delimiter);
338 match word_start {
339 None => CompletionContext::Command, Some(idx) => {
341 let prefix = before[..=idx].trim();
343 if prefix.is_empty()
344 || prefix.ends_with('|')
345 || prefix.ends_with(';')
346 || prefix.ends_with("&&")
347 || prefix.ends_with("||")
348 || prefix.ends_with("$(")
349 || prefix.ends_with("then")
350 || prefix.ends_with("else")
351 || prefix.ends_with("do")
352 {
353 CompletionContext::Command
354 } else {
355 CompletionContext::Path
356 }
357 }
358 }
359}
360
361impl Completer for KaishHelper {
364 type Candidate = Pair;
365
366 fn complete(
367 &self,
368 line: &str,
369 pos: usize,
370 ctx: &rustyline::Context<'_>,
371 ) -> rustyline::Result<(usize, Vec<Pair>)> {
372 match detect_completion_context(line, pos) {
373 CompletionContext::Command => {
374 let before = &line[..pos];
376 let word_start = before
377 .rfind(is_word_delimiter)
378 .map(|i| i + 1)
379 .unwrap_or(0);
380 let prefix = &line[word_start..pos];
381
382 let mut candidates = Vec::new();
383
384 let schemas = match self.handle.block_on(self.client.tool_schemas()) {
388 Ok(schemas) => schemas,
389 Err(e) => {
390 tracing::warn!("completion: tool_schemas failed: {e}");
391 Vec::new()
392 }
393 };
394 for schema in schemas {
395 if schema.name.starts_with(prefix) {
396 candidates.push(Pair {
397 display: schema.name.clone(),
398 replacement: schema.name.clone(),
399 });
400 }
401 }
402
403 candidates.sort_by(|a, b| a.display.cmp(&b.display));
404
405 Ok((word_start, candidates))
406 }
407
408 CompletionContext::Variable => {
409 let before = &line[..pos];
411 let (var_start, prefix) = if let Some(brace_pos) = before.rfind("${") {
412 let name_start = brace_pos + 2;
413 (brace_pos, &line[name_start..pos])
414 } else if let Some(dollar_pos) = before.rfind('$') {
415 let name_start = dollar_pos + 1;
416 (dollar_pos, &line[name_start..pos])
417 } else {
418 return Ok((pos, vec![]));
419 };
420
421 let vars = match self.handle.block_on(self.client.list_vars()) {
424 Ok(vars) => vars,
425 Err(e) => {
426 tracing::warn!("completion: list_vars failed: {e}");
427 Vec::new()
428 }
429 };
430
431 let mut candidates: Vec<Pair> = vars
432 .into_iter()
433 .filter(|(name, _)| name.starts_with(prefix))
434 .map(|(name, _)| {
435 let (display, replacement) = if before.contains("${") {
437 (name.clone(), format!("${{{name}}}"))
438 } else {
439 (name.clone(), format!("${name}"))
440 };
441 Pair {
442 display,
443 replacement,
444 }
445 })
446 .collect();
447
448 candidates.sort_by(|a, b| a.display.cmp(&b.display));
449
450 Ok((var_start, candidates))
451 }
452
453 CompletionContext::Path => self.path_completer.complete(line, pos, ctx),
454 }
455 }
456}
457
458impl Validator for KaishHelper {
459 fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
460 let input = ctx.input();
461 if input.trim().is_empty() {
462 return Ok(ValidationResult::Valid(None));
463 }
464 if self.is_incomplete(input) {
465 Ok(ValidationResult::Incomplete)
466 } else {
467 Ok(ValidationResult::Valid(None))
468 }
469 }
470}
471
472impl Highlighter for KaishHelper {
473 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
474 Cow::Borrowed(hint)
475 }
476}
477
478struct NoHint;
480impl Hint for NoHint {
481 fn display(&self) -> &str {
482 ""
483 }
484 fn completion(&self) -> Option<&str> {
485 None
486 }
487}
488
489impl Hinter for KaishHelper {
490 type Hint = NoHint;
491
492 fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
493 None
494 }
495}
496
497impl Helper for KaishHelper {}
498
499pub struct Repl {
503 client: EmbeddedClient,
504 runtime: Runtime,
505}
506
507impl Repl {
508 pub fn new() -> Result<Self> {
510 let config = KernelConfig::repl()
511 .with_interactive(true)
512 .with_initial_vars(os_env_vars());
513 let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
514 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
515
516 #[cfg(unix)]
518 if std::io::stdin().is_terminal() {
519 kernel.init_terminal();
520 }
521
522 Ok(Self {
523 client: EmbeddedClient::new(kernel),
524 runtime,
525 })
526 }
527
528 pub fn with_config(config: KernelConfig) -> Result<Self> {
530 let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
531 let runtime = Runtime::new().context("Failed to create tokio runtime")?;
532
533 #[cfg(unix)]
537 if std::io::stdin().is_terminal() {
538 kernel.init_terminal();
539 }
540
541 Ok(Self {
542 client: EmbeddedClient::new(kernel),
543 runtime,
544 })
545 }
546
547 pub fn with_root(root: PathBuf) -> Result<Self> {
549 let config = KernelConfig::repl()
550 .with_cwd(root)
551 .with_initial_vars(os_env_vars());
552 Self::with_config(config)
553 }
554
555 pub fn process_line(&mut self, line: &str) -> ProcessResult {
557 let trimmed = line.trim();
558
559 if trimmed.is_empty() {
561 return ProcessResult::Empty;
562 }
563
564 if matches!(trimmed, "exit" | "quit") {
566 return ProcessResult::Exit;
567 }
568
569 let client = self.client.clone();
573 let input = trimmed.to_string();
574 let result = self.runtime.block_on(async {
575 let mut sigint = tokio::signal::unix::signal(
576 tokio::signal::unix::SignalKind::interrupt(),
577 )?;
578 tokio::select! {
579 result = client.execute(&input) => result,
580 _ = sigint.recv() => {
581 client.cancel().await?;
582 Ok(ExecResult::failure(130, ""))
583 }
584 }
585 });
586
587 match result {
588 Ok(exec_result) => {
589 if exec_result.ok() && !exec_result.has_output() && exec_result.text_out().is_empty() {
590 ProcessResult::Empty
591 } else {
592 ProcessResult::Output(format_result(&exec_result))
593 }
594 }
595 Err(e) => ProcessResult::Output(format!("Error: {}", e)),
596 }
597 }
598}
599
600impl Default for Repl {
601 #[allow(clippy::expect_used)]
602 fn default() -> Self {
603 Self::new().expect("Failed to create REPL")
604 }
605}
606
607fn format_result(result: &ExecResult) -> String {
613 if result.has_output() {
615 let context = format::detect_context();
616 let formatted = format::format_output(result, context);
617
618 if !result.ok() && !result.err.is_empty() {
620 return format!("{}\n✗ code={} err=\"{}\"", formatted, result.code, result.err);
621 }
622 return formatted;
623 }
624
625 if result.ok() {
629 result.text_out().into_owned()
630 } else {
631 let mut output = String::new();
632 let text = result.text_out();
633 if !text.is_empty() {
634 output.push_str(&text);
635 if !output.ends_with('\n') {
636 output.push('\n');
637 }
638 }
639 if !result.err.is_empty() {
640 output.push_str(&format!("✗ {}", result.err));
641 } else {
642 output.push_str(&format!("✗ [exit {}]", result.code));
643 }
644 output
645 }
646}
647
648fn save_history(rl: &mut Editor<KaishHelper, DefaultHistory>, history_path: &Option<PathBuf>) {
652 if let Some(path) = history_path {
653 if let Some(parent) = path.parent()
654 && let Err(e) = std::fs::create_dir_all(parent) {
655 tracing::warn!("Failed to create history directory: {}", e);
656 }
657 if let Err(e) = rl.save_history(path) {
658 tracing::warn!("Failed to save history: {}", e);
659 }
660 }
661}
662
663fn load_history(rl: &mut Editor<KaishHelper, DefaultHistory>) -> Option<PathBuf> {
665 let history_path = directories::BaseDirs::new()
666 .map(|b| b.data_dir().join("kaish").join("history.txt"));
667 if let Some(ref path) = history_path
668 && let Err(e) = rl.load_history(path) {
669 let is_not_found = matches!(&e, ReadlineError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound);
670 if !is_not_found {
671 tracing::warn!("Failed to load history: {}", e);
672 }
673 }
674 history_path
675}
676
677fn load_rc_file(repl: &Repl) {
683 let candidates: Vec<PathBuf> = if let Ok(path) = std::env::var("KAISH_INIT") {
684 vec![PathBuf::from(path)]
685 } else {
686 vec![
687 kaish_kernel::paths::config_dir().join("init.kai"),
688 directories::BaseDirs::new()
689 .map(|b| b.home_dir().join(".kaishrc"))
690 .unwrap_or_else(|| PathBuf::from("/.kaishrc")),
691 ]
692 };
693
694 for path in &candidates {
695 if path.is_file() {
696 let cmd = format!(r#"source "{}""#, path.display());
697 if let Err(e) = repl.runtime.block_on(repl.client.execute(&cmd)) {
698 eprintln!("kaish: warning: error sourcing {}: {}", path.display(), e);
699 }
700 return;
701 }
702 }
703}
704
705fn resolve_prompt(repl: &Repl) -> String {
707 let has_fn = repl
708 .runtime
709 .block_on(repl.client.has_function("kaish_prompt"))
710 .unwrap_or(false);
711 if has_fn {
712 if let Ok(result) = repl.runtime.block_on(repl.client.execute("kaish_prompt")) {
713 if result.ok() {
714 let text = result.text_out().trim_end().to_string();
715 if !text.is_empty() {
716 return text;
717 }
718 }
719 }
720 }
721 "会sh> ".to_string()
722}
723
724pub fn run_with_overlay(overlay: bool) -> Result<()> {
731 println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
732 use kaish_kernel::help::{compose, Recipe, SchemaContent};
733
734 if overlay {
735 println!("[overlay mode: writes are virtual — use 'kaish-vfs commit' to apply]");
736 }
737 println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
738
739 let config = KernelConfig::repl()
740 .with_interactive(true)
741 .with_initial_vars(os_env_vars())
742 .with_overlay(overlay);
743 let mut repl = Repl::with_config(config)?;
744
745 load_rc_file(&repl);
747
748 let helper = KaishHelper::new(
749 Box::new(repl.client.clone()),
750 repl.runtime.handle().clone(),
751 );
752
753 let mut rl: Editor<KaishHelper, DefaultHistory> =
754 Editor::new().context("Failed to create editor")?;
755 rl.set_helper(Some(helper));
756
757 let history_path = load_history(&mut rl);
758
759 loop {
760 let prompt_string = resolve_prompt(&repl);
761 let prompt: &str = &prompt_string;
762
763 match rl.readline(prompt) {
764 Ok(line) => {
765 if let Err(e) = rl.add_history_entry(line.as_str()) {
766 tracing::warn!("Failed to add history entry: {}", e);
767 }
768
769 match repl.process_line(&line) {
770 ProcessResult::Output(output) => {
771 if output.ends_with('\n') {
772 print!("{}", output);
773 } else {
774 println!("{}", output);
775 }
776 }
777 ProcessResult::Empty => {}
778 ProcessResult::Exit => {
779 save_history(&mut rl, &history_path);
780 return Ok(());
781 }
782 }
783 }
784 Err(ReadlineError::Interrupted) => {
785 println!("^C");
786 continue;
787 }
788 Err(ReadlineError::Eof) => {
789 println!("^D");
790 break;
791 }
792 Err(err) => {
793 eprintln!("Error: {}", err);
794 break;
795 }
796 }
797 }
798
799 save_history(&mut rl, &history_path);
800
801 Ok(())
802}
803
804pub fn run() -> Result<()> {
806 println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
807 use kaish_kernel::help::{compose, Recipe, SchemaContent};
810 println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
811
812 let mut repl = Repl::new()?;
813
814 load_rc_file(&repl);
816
817 let helper = KaishHelper::new(
821 Box::new(repl.client.clone()),
822 repl.runtime.handle().clone(),
823 );
824
825 let mut rl: Editor<KaishHelper, DefaultHistory> =
826 Editor::new().context("Failed to create editor")?;
827 rl.set_helper(Some(helper));
828
829 let history_path = load_history(&mut rl);
830
831 loop {
832 let prompt_string = resolve_prompt(&repl);
834 let prompt: &str = &prompt_string;
835
836 match rl.readline(prompt) {
837 Ok(line) => {
838 if let Err(e) = rl.add_history_entry(line.as_str()) {
839 tracing::warn!("Failed to add history entry: {}", e);
840 }
841
842 match repl.process_line(&line) {
843 ProcessResult::Output(output) => {
844 if output.ends_with('\n') {
845 print!("{}", output);
846 } else {
847 println!("{}", output);
848 }
849 }
850 ProcessResult::Empty => {}
851 ProcessResult::Exit => {
852 save_history(&mut rl, &history_path);
853 return Ok(());
854 }
855 }
856 }
857 Err(ReadlineError::Interrupted) => {
858 println!("^C");
859 continue;
860 }
861 Err(ReadlineError::Eof) => {
862 println!("^D");
863 break;
864 }
865 Err(err) => {
866 eprintln!("Error: {}", err);
867 break;
868 }
869 }
870 }
871
872 save_history(&mut rl, &history_path);
873
874 Ok(())
875}
876
877#[cfg(test)]
880mod tests {
881 use super::*;
882
883 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
886 move |key| {
887 pairs
888 .iter()
889 .find(|(k, _)| *k == key)
890 .map(|(_, v)| v.to_string())
891 }
892 }
893
894 #[test]
895 fn trace_env_empty_yields_default_options() {
896 let opts = parse_trace_env(env_of(&[]));
897 assert!(opts.traceparent.is_none());
898 assert!(opts.tracestate.is_none());
899 assert!(opts.baggage.is_empty());
900 }
901
902 #[test]
903 fn trace_env_reads_traceparent_and_tracestate() {
904 let opts = parse_trace_env(env_of(&[
905 ("TRACEPARENT", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
906 ("TRACESTATE", "vendor=opaque"),
907 ]));
908 assert_eq!(
909 opts.traceparent.as_deref(),
910 Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
911 );
912 assert_eq!(opts.tracestate.as_deref(), Some("vendor=opaque"));
913 }
914
915 #[test]
916 fn trace_env_drops_tracestate_without_traceparent() {
917 let opts = parse_trace_env(env_of(&[("TRACESTATE", "vendor=opaque")]));
918 assert!(opts.traceparent.is_none());
919 assert!(opts.tracestate.is_none(), "tracestate alone is meaningless");
920 }
921
922 #[test]
923 fn trace_env_treats_empty_string_as_unset() {
924 let opts = parse_trace_env(env_of(&[("TRACEPARENT", ""), ("BAGGAGE", "")]));
925 assert!(opts.traceparent.is_none());
926 assert!(opts.baggage.is_empty());
927 }
928
929 #[test]
930 fn trace_env_parses_baggage() {
931 let opts = parse_trace_env(env_of(&[("BAGGAGE", "owner=atobey,tenant=acme")]));
932 assert_eq!(opts.baggage.get("owner").map(String::as_str), Some("atobey"));
933 assert_eq!(opts.baggage.get("tenant").map(String::as_str), Some("acme"));
934 }
935
936 #[test]
937 fn baggage_drops_properties_and_skips_malformed() {
938 let map = parse_w3c_baggage("owner=atobey;ttl=60 , broken , tenant = acme ");
941 assert_eq!(map.get("owner").map(String::as_str), Some("atobey"));
942 assert_eq!(map.get("tenant").map(String::as_str), Some("acme"));
943 assert!(!map.contains_key("broken"), "member without '=' is skipped");
944 assert_eq!(map.len(), 2);
945 }
946
947 #[test]
948 fn test_shell_words_simple() {
949 assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
950 }
951
952 #[test]
953 fn test_shell_words_semicolons() {
954 assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
955 }
956
957 #[test]
958 fn test_shell_words_quoted() {
959 assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
961 }
962
963 #[test]
964 fn test_shell_words_single_quoted() {
965 assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
967 }
968
969 #[test]
970 fn test_is_incomplete_if_block() {
971 let helper = make_test_helper();
972 assert!(helper.is_incomplete("if true; then"));
973 assert!(helper.is_incomplete("if true; then\n echo hello"));
974 assert!(!helper.is_incomplete("if true; then\n echo hello\nfi"));
975 }
976
977 #[test]
978 fn test_is_incomplete_for_loop() {
979 let helper = make_test_helper();
980 assert!(helper.is_incomplete("for x in 1 2 3; do"));
981 assert!(!helper.is_incomplete("for x in 1 2 3; do\n echo $x\ndone"));
982 }
983
984 #[test]
985 fn test_is_incomplete_unclosed_single_quote() {
986 let helper = make_test_helper();
987 assert!(helper.is_incomplete("echo 'hello"));
988 assert!(!helper.is_incomplete("echo 'hello'"));
989 }
990
991 #[test]
992 fn test_is_incomplete_unclosed_double_quote() {
993 let helper = make_test_helper();
994 assert!(helper.is_incomplete("echo \"hello"));
995 assert!(!helper.is_incomplete("echo \"hello\""));
996 }
997
998 #[test]
999 fn test_is_incomplete_backslash_continuation() {
1000 let helper = make_test_helper();
1001 assert!(helper.is_incomplete("echo hello \\"));
1002 assert!(!helper.is_incomplete("echo hello"));
1003 }
1004
1005 #[test]
1006 fn test_is_incomplete_while_loop() {
1007 let helper = make_test_helper();
1008 assert!(helper.is_incomplete("while true; do"));
1009 assert!(!helper.is_incomplete("while true; do\n echo loop\ndone"));
1010 }
1011
1012 #[test]
1013 fn test_is_incomplete_nested() {
1014 let helper = make_test_helper();
1015 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do"));
1016 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done"));
1017 assert!(!helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done\nfi"));
1018 }
1019
1020 #[test]
1021 fn test_is_incomplete_empty() {
1022 let helper = make_test_helper();
1023 assert!(!helper.is_incomplete(""));
1024 assert!(!helper.is_incomplete("echo hello"));
1025 }
1026
1027 #[test]
1028 fn test_is_incomplete_unterminated_heredoc() {
1029 let helper = make_test_helper();
1030 assert!(helper.is_incomplete("cat <<EOF"));
1032 assert!(helper.is_incomplete("cat <<EOF\nhello"));
1033 assert!(helper.is_incomplete("cat <<-DONE\n\thi"));
1035 assert!(helper.is_incomplete("cat <<'EOF'\n$VAR"));
1036 assert!(!helper.is_incomplete("cat <<EOF\nhello\nEOF"));
1038 assert!(!helper.is_incomplete("cat <<-DONE\n\thi\n\tDONE"));
1039 }
1040
1041 #[test]
1042 fn test_detect_context_command_start() {
1043 assert!(matches!(
1044 detect_completion_context("", 0),
1045 CompletionContext::Command
1046 ));
1047 assert!(matches!(
1048 detect_completion_context("ec", 2),
1049 CompletionContext::Command
1050 ));
1051 }
1052
1053 #[test]
1054 fn test_detect_context_after_pipe() {
1055 assert!(matches!(
1056 detect_completion_context("echo hello | gr", 15),
1057 CompletionContext::Command
1058 ));
1059 }
1060
1061 #[test]
1062 fn test_detect_context_variable() {
1063 assert!(matches!(
1064 detect_completion_context("echo $HO", 8),
1065 CompletionContext::Variable
1066 ));
1067 assert!(matches!(
1068 detect_completion_context("echo ${HO", 9),
1069 CompletionContext::Variable
1070 ));
1071 }
1072
1073 #[test]
1074 fn test_detect_context_path() {
1075 assert!(matches!(
1076 detect_completion_context("cat /etc/hos", 12),
1077 CompletionContext::Path
1078 ));
1079 }
1080
1081 #[test]
1082 fn test_detect_context_command_substitution() {
1083 assert!(matches!(
1085 detect_completion_context("echo $(ca", 9),
1086 CompletionContext::Command
1087 ));
1088 assert!(matches!(
1089 detect_completion_context("X=$(ec", 6),
1090 CompletionContext::Command
1091 ));
1092 }
1093
1094 #[test]
1095 fn test_shell_words_comments() {
1096 assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
1098 assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
1099 }
1100
1101 #[test]
1102 fn test_is_incomplete_comment_with_keyword() {
1103 let helper = make_test_helper();
1104 assert!(!helper.is_incomplete("# if this happens"));
1106 assert!(!helper.is_incomplete("echo hello # if we do this"));
1107 }
1108
1109 fn make_test_helper() -> KaishHelper {
1111 let config = KernelConfig::transient();
1112 let kernel = Kernel::new(config).expect("test kernel");
1113 let client = EmbeddedClient::new(kernel);
1114 let rt = Runtime::new().expect("test runtime");
1115 KaishHelper::new(Box::new(client), rt.handle().clone())
1116 }
1117
1118 #[test]
1122 fn test_completion_through_client() {
1123 let config = KernelConfig::transient();
1124 let kernel = Kernel::new(config).expect("test kernel");
1125 let client = EmbeddedClient::new(kernel);
1126 let rt = Runtime::new().expect("test runtime");
1127
1128 rt.block_on(client.set_var("MYVAR", Value::String("hi".into())))
1131 .expect("set_var failed");
1132
1133 let helper = KaishHelper::new(Box::new(client), rt.handle().clone());
1134 let history = DefaultHistory::new();
1135 let ctx = rustyline::Context::new(&history);
1136
1137 let (start, candidates) = helper.complete("ec", 2, &ctx).expect("command completion");
1139 assert_eq!(start, 0);
1140 assert!(
1141 candidates.iter().any(|p| p.replacement == "echo"),
1142 "expected `echo` among command candidates, got {:?}",
1143 candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
1144 );
1145
1146 let (start, candidates) = helper.complete("$MY", 3, &ctx).expect("variable completion");
1148 assert_eq!(start, 0);
1149 assert!(
1150 candidates.iter().any(|p| p.replacement == "$MYVAR"),
1151 "expected `$MYVAR` among variable candidates, got {:?}",
1152 candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
1153 );
1154 }
1155}