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() -> Result<()> {
728 println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
729 use kaish_kernel::help::{compose, Recipe, SchemaContent};
732 println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
733
734 let mut repl = Repl::new()?;
735
736 load_rc_file(&repl);
738
739 let helper = KaishHelper::new(
743 Box::new(repl.client.clone()),
744 repl.runtime.handle().clone(),
745 );
746
747 let mut rl: Editor<KaishHelper, DefaultHistory> =
748 Editor::new().context("Failed to create editor")?;
749 rl.set_helper(Some(helper));
750
751 let history_path = load_history(&mut rl);
752
753 loop {
754 let prompt_string = resolve_prompt(&repl);
756 let prompt: &str = &prompt_string;
757
758 match rl.readline(prompt) {
759 Ok(line) => {
760 if let Err(e) = rl.add_history_entry(line.as_str()) {
761 tracing::warn!("Failed to add history entry: {}", e);
762 }
763
764 match repl.process_line(&line) {
765 ProcessResult::Output(output) => {
766 if output.ends_with('\n') {
767 print!("{}", output);
768 } else {
769 println!("{}", output);
770 }
771 }
772 ProcessResult::Empty => {}
773 ProcessResult::Exit => {
774 save_history(&mut rl, &history_path);
775 return Ok(());
776 }
777 }
778 }
779 Err(ReadlineError::Interrupted) => {
780 println!("^C");
781 continue;
782 }
783 Err(ReadlineError::Eof) => {
784 println!("^D");
785 break;
786 }
787 Err(err) => {
788 eprintln!("Error: {}", err);
789 break;
790 }
791 }
792 }
793
794 save_history(&mut rl, &history_path);
795
796 Ok(())
797}
798
799#[cfg(test)]
802mod tests {
803 use super::*;
804
805 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
808 move |key| {
809 pairs
810 .iter()
811 .find(|(k, _)| *k == key)
812 .map(|(_, v)| v.to_string())
813 }
814 }
815
816 #[test]
817 fn trace_env_empty_yields_default_options() {
818 let opts = parse_trace_env(env_of(&[]));
819 assert!(opts.traceparent.is_none());
820 assert!(opts.tracestate.is_none());
821 assert!(opts.baggage.is_empty());
822 }
823
824 #[test]
825 fn trace_env_reads_traceparent_and_tracestate() {
826 let opts = parse_trace_env(env_of(&[
827 ("TRACEPARENT", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
828 ("TRACESTATE", "vendor=opaque"),
829 ]));
830 assert_eq!(
831 opts.traceparent.as_deref(),
832 Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
833 );
834 assert_eq!(opts.tracestate.as_deref(), Some("vendor=opaque"));
835 }
836
837 #[test]
838 fn trace_env_drops_tracestate_without_traceparent() {
839 let opts = parse_trace_env(env_of(&[("TRACESTATE", "vendor=opaque")]));
840 assert!(opts.traceparent.is_none());
841 assert!(opts.tracestate.is_none(), "tracestate alone is meaningless");
842 }
843
844 #[test]
845 fn trace_env_treats_empty_string_as_unset() {
846 let opts = parse_trace_env(env_of(&[("TRACEPARENT", ""), ("BAGGAGE", "")]));
847 assert!(opts.traceparent.is_none());
848 assert!(opts.baggage.is_empty());
849 }
850
851 #[test]
852 fn trace_env_parses_baggage() {
853 let opts = parse_trace_env(env_of(&[("BAGGAGE", "owner=atobey,tenant=acme")]));
854 assert_eq!(opts.baggage.get("owner").map(String::as_str), Some("atobey"));
855 assert_eq!(opts.baggage.get("tenant").map(String::as_str), Some("acme"));
856 }
857
858 #[test]
859 fn baggage_drops_properties_and_skips_malformed() {
860 let map = parse_w3c_baggage("owner=atobey;ttl=60 , broken , tenant = acme ");
863 assert_eq!(map.get("owner").map(String::as_str), Some("atobey"));
864 assert_eq!(map.get("tenant").map(String::as_str), Some("acme"));
865 assert!(!map.contains_key("broken"), "member without '=' is skipped");
866 assert_eq!(map.len(), 2);
867 }
868
869 #[test]
870 fn test_shell_words_simple() {
871 assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
872 }
873
874 #[test]
875 fn test_shell_words_semicolons() {
876 assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
877 }
878
879 #[test]
880 fn test_shell_words_quoted() {
881 assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
883 }
884
885 #[test]
886 fn test_shell_words_single_quoted() {
887 assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
889 }
890
891 #[test]
892 fn test_is_incomplete_if_block() {
893 let helper = make_test_helper();
894 assert!(helper.is_incomplete("if true; then"));
895 assert!(helper.is_incomplete("if true; then\n echo hello"));
896 assert!(!helper.is_incomplete("if true; then\n echo hello\nfi"));
897 }
898
899 #[test]
900 fn test_is_incomplete_for_loop() {
901 let helper = make_test_helper();
902 assert!(helper.is_incomplete("for x in 1 2 3; do"));
903 assert!(!helper.is_incomplete("for x in 1 2 3; do\n echo $x\ndone"));
904 }
905
906 #[test]
907 fn test_is_incomplete_unclosed_single_quote() {
908 let helper = make_test_helper();
909 assert!(helper.is_incomplete("echo 'hello"));
910 assert!(!helper.is_incomplete("echo 'hello'"));
911 }
912
913 #[test]
914 fn test_is_incomplete_unclosed_double_quote() {
915 let helper = make_test_helper();
916 assert!(helper.is_incomplete("echo \"hello"));
917 assert!(!helper.is_incomplete("echo \"hello\""));
918 }
919
920 #[test]
921 fn test_is_incomplete_backslash_continuation() {
922 let helper = make_test_helper();
923 assert!(helper.is_incomplete("echo hello \\"));
924 assert!(!helper.is_incomplete("echo hello"));
925 }
926
927 #[test]
928 fn test_is_incomplete_while_loop() {
929 let helper = make_test_helper();
930 assert!(helper.is_incomplete("while true; do"));
931 assert!(!helper.is_incomplete("while true; do\n echo loop\ndone"));
932 }
933
934 #[test]
935 fn test_is_incomplete_nested() {
936 let helper = make_test_helper();
937 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do"));
938 assert!(helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done"));
939 assert!(!helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done\nfi"));
940 }
941
942 #[test]
943 fn test_is_incomplete_empty() {
944 let helper = make_test_helper();
945 assert!(!helper.is_incomplete(""));
946 assert!(!helper.is_incomplete("echo hello"));
947 }
948
949 #[test]
950 fn test_is_incomplete_unterminated_heredoc() {
951 let helper = make_test_helper();
952 assert!(helper.is_incomplete("cat <<EOF"));
954 assert!(helper.is_incomplete("cat <<EOF\nhello"));
955 assert!(helper.is_incomplete("cat <<-DONE\n\thi"));
957 assert!(helper.is_incomplete("cat <<'EOF'\n$VAR"));
958 assert!(!helper.is_incomplete("cat <<EOF\nhello\nEOF"));
960 assert!(!helper.is_incomplete("cat <<-DONE\n\thi\n\tDONE"));
961 }
962
963 #[test]
964 fn test_detect_context_command_start() {
965 assert!(matches!(
966 detect_completion_context("", 0),
967 CompletionContext::Command
968 ));
969 assert!(matches!(
970 detect_completion_context("ec", 2),
971 CompletionContext::Command
972 ));
973 }
974
975 #[test]
976 fn test_detect_context_after_pipe() {
977 assert!(matches!(
978 detect_completion_context("echo hello | gr", 15),
979 CompletionContext::Command
980 ));
981 }
982
983 #[test]
984 fn test_detect_context_variable() {
985 assert!(matches!(
986 detect_completion_context("echo $HO", 8),
987 CompletionContext::Variable
988 ));
989 assert!(matches!(
990 detect_completion_context("echo ${HO", 9),
991 CompletionContext::Variable
992 ));
993 }
994
995 #[test]
996 fn test_detect_context_path() {
997 assert!(matches!(
998 detect_completion_context("cat /etc/hos", 12),
999 CompletionContext::Path
1000 ));
1001 }
1002
1003 #[test]
1004 fn test_detect_context_command_substitution() {
1005 assert!(matches!(
1007 detect_completion_context("echo $(ca", 9),
1008 CompletionContext::Command
1009 ));
1010 assert!(matches!(
1011 detect_completion_context("X=$(ec", 6),
1012 CompletionContext::Command
1013 ));
1014 }
1015
1016 #[test]
1017 fn test_shell_words_comments() {
1018 assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
1020 assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
1021 }
1022
1023 #[test]
1024 fn test_is_incomplete_comment_with_keyword() {
1025 let helper = make_test_helper();
1026 assert!(!helper.is_incomplete("# if this happens"));
1028 assert!(!helper.is_incomplete("echo hello # if we do this"));
1029 }
1030
1031 fn make_test_helper() -> KaishHelper {
1033 let config = KernelConfig::transient();
1034 let kernel = Kernel::new(config).expect("test kernel");
1035 let client = EmbeddedClient::new(kernel);
1036 let rt = Runtime::new().expect("test runtime");
1037 KaishHelper::new(Box::new(client), rt.handle().clone())
1038 }
1039
1040 #[test]
1044 fn test_completion_through_client() {
1045 let config = KernelConfig::transient();
1046 let kernel = Kernel::new(config).expect("test kernel");
1047 let client = EmbeddedClient::new(kernel);
1048 let rt = Runtime::new().expect("test runtime");
1049
1050 rt.block_on(client.set_var("MYVAR", Value::String("hi".into())))
1053 .expect("set_var failed");
1054
1055 let helper = KaishHelper::new(Box::new(client), rt.handle().clone());
1056 let history = DefaultHistory::new();
1057 let ctx = rustyline::Context::new(&history);
1058
1059 let (start, candidates) = helper.complete("ec", 2, &ctx).expect("command completion");
1061 assert_eq!(start, 0);
1062 assert!(
1063 candidates.iter().any(|p| p.replacement == "echo"),
1064 "expected `echo` among command candidates, got {:?}",
1065 candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
1066 );
1067
1068 let (start, candidates) = helper.complete("$MY", 3, &ctx).expect("variable completion");
1070 assert_eq!(start, 0);
1071 assert!(
1072 candidates.iter().any(|p| p.replacement == "$MYVAR"),
1073 "expected `$MYVAR` among variable candidates, got {:?}",
1074 candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
1075 );
1076 }
1077}