pub mod format;
use std::borrow::Cow;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{Context, Result};
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::{Hint, Hinter};
use rustyline::history::DefaultHistory;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{Editor, Helper};
use tokio::runtime::Runtime;
use kaish_client::{EmbeddedClient, KernelClient};
use kaish_kernel::ast::Value;
use kaish_kernel::interpreter::ExecResult;
use kaish_kernel::{ExecuteOptions, Kernel, KernelConfig};
pub fn os_env_vars() -> std::collections::HashMap<String, Value> {
std::env::vars()
.map(|(k, v)| (k, Value::String(v)))
.collect()
}
pub fn trace_options_from_env() -> ExecuteOptions {
parse_trace_env(|key| std::env::var(key).ok())
}
fn parse_trace_env(get: impl Fn(&str) -> Option<String>) -> ExecuteOptions {
let mut opts = ExecuteOptions::new();
if let Some(traceparent) = get("TRACEPARENT").filter(|s| !s.is_empty()) {
opts = opts.with_traceparent(traceparent);
if let Some(tracestate) = get("TRACESTATE").filter(|s| !s.is_empty()) {
opts = opts.with_tracestate(tracestate);
}
}
if let Some(raw) = get("BAGGAGE").filter(|s| !s.is_empty()) {
let baggage = parse_w3c_baggage(&raw);
if !baggage.is_empty() {
opts = opts.with_baggage(baggage);
}
}
opts
}
fn parse_w3c_baggage(raw: &str) -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
for entry in raw.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some((key, value)) = entry.split_once('=') else {
tracing::debug!(entry, "skipping malformed BAGGAGE entry (no '=')");
continue;
};
let value = value.split(';').next().unwrap_or(value);
map.insert(key.trim().to_string(), value.trim().to_string());
}
map
}
#[derive(Debug)]
pub enum ProcessResult {
Output(String),
Empty,
Exit,
}
struct KaishHelper {
client: Box<dyn KernelClient>,
handle: tokio::runtime::Handle,
path_completer: FilenameCompleter,
}
impl KaishHelper {
fn new(client: Box<dyn KernelClient>, handle: tokio::runtime::Handle) -> Self {
Self {
client,
handle,
path_completer: FilenameCompleter::new(),
}
}
fn is_incomplete(&self, input: &str) -> bool {
if input.trim_end().ends_with('\\') {
return true;
}
let mut depth: i32 = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
for line in input.lines() {
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' if !in_single_quote => {
chars.next();
}
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
_ => {}
}
}
}
if in_single_quote || in_double_quote {
return true;
}
for word in shell_words(input) {
match word.as_str() {
"if" | "for" | "while" | "case" => depth += 1,
"fi" | "done" | "esac" => depth -= 1,
"then" | "else" | "elif" => {
}
_ => {}
}
}
if depth > 0 {
return true;
}
if let Err(errs) = kaish_kernel::lexer::tokenize(input)
&& errs
.iter()
.any(|e| matches!(e.token, kaish_kernel::lexer::LexerError::UnterminatedHeredoc { .. }))
{
return true;
}
false
}
}
fn shell_words(input: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut in_comment = false;
let mut prev_was_backslash = false;
for ch in input.chars() {
if in_comment {
if ch == '\n' {
in_comment = false;
}
continue;
}
if prev_was_backslash {
prev_was_backslash = false;
if !in_single_quote {
current.push(ch);
continue;
}
}
match ch {
'\\' if !in_single_quote => {
prev_was_backslash = true;
}
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
'#' if !in_single_quote && !in_double_quote => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
in_comment = true;
}
_ if ch.is_whitespace() && !in_single_quote && !in_double_quote => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
';' if !in_single_quote && !in_double_quote => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
words.push(current);
}
words
}
enum CompletionContext {
Command,
Variable,
Path,
}
fn is_word_delimiter(c: char) -> bool {
c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
}
fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
let before = &line[..pos];
let bytes = before.as_bytes();
let mut i = pos;
while i > 0 {
i -= 1;
let b = bytes[i];
if b == b'$' {
if i + 1 < pos && bytes[i + 1] == b'(' {
break;
}
return CompletionContext::Variable;
}
if b == b'{' && i > 0 && bytes[i - 1] == b'$' {
return CompletionContext::Variable;
}
if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
break;
}
}
let trimmed = before.trim();
if trimmed.is_empty()
|| trimmed.ends_with('|')
|| trimmed.ends_with(';')
|| trimmed.ends_with("&&")
|| trimmed.ends_with("||")
|| trimmed.ends_with("$(")
{
return CompletionContext::Command;
}
let word_start = before.rfind(is_word_delimiter);
match word_start {
None => CompletionContext::Command, Some(idx) => {
let prefix = before[..=idx].trim();
if prefix.is_empty()
|| prefix.ends_with('|')
|| prefix.ends_with(';')
|| prefix.ends_with("&&")
|| prefix.ends_with("||")
|| prefix.ends_with("$(")
|| prefix.ends_with("then")
|| prefix.ends_with("else")
|| prefix.ends_with("do")
{
CompletionContext::Command
} else {
CompletionContext::Path
}
}
}
}
impl Completer for KaishHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
match detect_completion_context(line, pos) {
CompletionContext::Command => {
let before = &line[..pos];
let word_start = before
.rfind(is_word_delimiter)
.map(|i| i + 1)
.unwrap_or(0);
let prefix = &line[word_start..pos];
let mut candidates = Vec::new();
let schemas = match self.handle.block_on(self.client.tool_schemas()) {
Ok(schemas) => schemas,
Err(e) => {
tracing::warn!("completion: tool_schemas failed: {e}");
Vec::new()
}
};
for schema in schemas {
if schema.name.starts_with(prefix) {
candidates.push(Pair {
display: schema.name.clone(),
replacement: schema.name.clone(),
});
}
}
candidates.sort_by(|a, b| a.display.cmp(&b.display));
Ok((word_start, candidates))
}
CompletionContext::Variable => {
let before = &line[..pos];
let (var_start, prefix) = if let Some(brace_pos) = before.rfind("${") {
let name_start = brace_pos + 2;
(brace_pos, &line[name_start..pos])
} else if let Some(dollar_pos) = before.rfind('$') {
let name_start = dollar_pos + 1;
(dollar_pos, &line[name_start..pos])
} else {
return Ok((pos, vec![]));
};
let vars = match self.handle.block_on(self.client.list_vars()) {
Ok(vars) => vars,
Err(e) => {
tracing::warn!("completion: list_vars failed: {e}");
Vec::new()
}
};
let mut candidates: Vec<Pair> = vars
.into_iter()
.filter(|(name, _)| name.starts_with(prefix))
.map(|(name, _)| {
let (display, replacement) = if before.contains("${") {
(name.clone(), format!("${{{name}}}"))
} else {
(name.clone(), format!("${name}"))
};
Pair {
display,
replacement,
}
})
.collect();
candidates.sort_by(|a, b| a.display.cmp(&b.display));
Ok((var_start, candidates))
}
CompletionContext::Path => self.path_completer.complete(line, pos, ctx),
}
}
}
impl Validator for KaishHelper {
fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
let input = ctx.input();
if input.trim().is_empty() {
return Ok(ValidationResult::Valid(None));
}
if self.is_incomplete(input) {
Ok(ValidationResult::Incomplete)
} else {
Ok(ValidationResult::Valid(None))
}
}
}
impl Highlighter for KaishHelper {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Borrowed(hint)
}
}
struct NoHint;
impl Hint for NoHint {
fn display(&self) -> &str {
""
}
fn completion(&self) -> Option<&str> {
None
}
}
impl Hinter for KaishHelper {
type Hint = NoHint;
fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
None
}
}
impl Helper for KaishHelper {}
pub struct Repl {
client: EmbeddedClient,
runtime: Runtime,
}
impl Repl {
pub fn new() -> Result<Self> {
let config = KernelConfig::repl()
.with_interactive(true)
.with_initial_vars(os_env_vars());
let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
let runtime = Runtime::new().context("Failed to create tokio runtime")?;
#[cfg(unix)]
if std::io::stdin().is_terminal() {
kernel.init_terminal();
}
Ok(Self {
client: EmbeddedClient::new(kernel),
runtime,
})
}
pub fn with_config(config: KernelConfig) -> Result<Self> {
let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
let runtime = Runtime::new().context("Failed to create tokio runtime")?;
#[cfg(unix)]
if std::io::stdin().is_terminal() {
kernel.init_terminal();
}
Ok(Self {
client: EmbeddedClient::new(kernel),
runtime,
})
}
pub fn with_root(root: PathBuf) -> Result<Self> {
let config = KernelConfig::repl()
.with_cwd(root)
.with_initial_vars(os_env_vars());
Self::with_config(config)
}
pub fn process_line(&mut self, line: &str) -> ProcessResult {
let trimmed = line.trim();
if trimmed.is_empty() {
return ProcessResult::Empty;
}
if matches!(trimmed, "exit" | "quit") {
return ProcessResult::Exit;
}
let client = self.client.clone();
let input = trimmed.to_string();
let result = self.runtime.block_on(async {
let mut sigint = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::interrupt(),
)?;
tokio::select! {
result = client.execute(&input) => result,
_ = sigint.recv() => {
client.cancel().await?;
Ok(ExecResult::failure(130, ""))
}
}
});
match result {
Ok(exec_result) => {
if exec_result.ok() && !exec_result.has_output() && exec_result.text_out().is_empty() {
ProcessResult::Empty
} else {
ProcessResult::Output(format_result(&exec_result))
}
}
Err(e) => ProcessResult::Output(format!("Error: {}", e)),
}
}
}
impl Default for Repl {
#[allow(clippy::expect_used)]
fn default() -> Self {
Self::new().expect("Failed to create REPL")
}
}
fn format_result(result: &ExecResult) -> String {
if result.has_output() {
let context = format::detect_context();
let formatted = format::format_output(result, context);
if !result.ok() && !result.err.is_empty() {
return format!("{}\n✗ code={} err=\"{}\"", formatted, result.code, result.err);
}
return formatted;
}
if result.ok() {
result.text_out().into_owned()
} else {
let mut output = String::new();
let text = result.text_out();
if !text.is_empty() {
output.push_str(&text);
if !output.ends_with('\n') {
output.push('\n');
}
}
if !result.err.is_empty() {
output.push_str(&format!("✗ {}", result.err));
} else {
output.push_str(&format!("✗ [exit {}]", result.code));
}
output
}
}
fn save_history(rl: &mut Editor<KaishHelper, DefaultHistory>, history_path: &Option<PathBuf>) {
if let Some(path) = history_path {
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!("Failed to create history directory: {}", e);
}
if let Err(e) = rl.save_history(path) {
tracing::warn!("Failed to save history: {}", e);
}
}
}
fn load_history(rl: &mut Editor<KaishHelper, DefaultHistory>) -> Option<PathBuf> {
let history_path = directories::BaseDirs::new()
.map(|b| b.data_dir().join("kaish").join("history.txt"));
if let Some(ref path) = history_path
&& let Err(e) = rl.load_history(path) {
let is_not_found = matches!(&e, ReadlineError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound);
if !is_not_found {
tracing::warn!("Failed to load history: {}", e);
}
}
history_path
}
fn load_rc_file(repl: &Repl) {
let candidates: Vec<PathBuf> = if let Ok(path) = std::env::var("KAISH_INIT") {
vec![PathBuf::from(path)]
} else {
vec![
kaish_kernel::paths::config_dir().join("init.kai"),
directories::BaseDirs::new()
.map(|b| b.home_dir().join(".kaishrc"))
.unwrap_or_else(|| PathBuf::from("/.kaishrc")),
]
};
for path in &candidates {
if path.is_file() {
let cmd = format!(r#"source "{}""#, path.display());
if let Err(e) = repl.runtime.block_on(repl.client.execute(&cmd)) {
eprintln!("kaish: warning: error sourcing {}: {}", path.display(), e);
}
return;
}
}
}
fn resolve_prompt(repl: &Repl) -> String {
let has_fn = repl
.runtime
.block_on(repl.client.has_function("kaish_prompt"))
.unwrap_or(false);
if has_fn {
if let Ok(result) = repl.runtime.block_on(repl.client.execute("kaish_prompt")) {
if result.ok() {
let text = result.text_out().trim_end().to_string();
if !text.is_empty() {
return text;
}
}
}
}
"会sh> ".to_string()
}
pub fn run_with_overlay(overlay: bool) -> Result<()> {
println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
use kaish_kernel::help::{compose, Recipe, SchemaContent};
if overlay {
println!("[overlay mode: writes are virtual — use 'kaish-vfs commit' to apply]");
}
println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
let config = KernelConfig::repl()
.with_interactive(true)
.with_initial_vars(os_env_vars())
.with_overlay(overlay);
let mut repl = Repl::with_config(config)?;
load_rc_file(&repl);
let helper = KaishHelper::new(
Box::new(repl.client.clone()),
repl.runtime.handle().clone(),
);
let mut rl: Editor<KaishHelper, DefaultHistory> =
Editor::new().context("Failed to create editor")?;
rl.set_helper(Some(helper));
let history_path = load_history(&mut rl);
loop {
let prompt_string = resolve_prompt(&repl);
let prompt: &str = &prompt_string;
match rl.readline(prompt) {
Ok(line) => {
if let Err(e) = rl.add_history_entry(line.as_str()) {
tracing::warn!("Failed to add history entry: {}", e);
}
match repl.process_line(&line) {
ProcessResult::Output(output) => {
if output.ends_with('\n') {
print!("{}", output);
} else {
println!("{}", output);
}
}
ProcessResult::Empty => {}
ProcessResult::Exit => {
save_history(&mut rl, &history_path);
return Ok(());
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continue;
}
Err(ReadlineError::Eof) => {
println!("^D");
break;
}
Err(err) => {
eprintln!("Error: {}", err);
break;
}
}
}
save_history(&mut rl, &history_path);
Ok(())
}
pub fn run() -> Result<()> {
println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
use kaish_kernel::help::{compose, Recipe, SchemaContent};
println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
let mut repl = Repl::new()?;
load_rc_file(&repl);
let helper = KaishHelper::new(
Box::new(repl.client.clone()),
repl.runtime.handle().clone(),
);
let mut rl: Editor<KaishHelper, DefaultHistory> =
Editor::new().context("Failed to create editor")?;
rl.set_helper(Some(helper));
let history_path = load_history(&mut rl);
loop {
let prompt_string = resolve_prompt(&repl);
let prompt: &str = &prompt_string;
match rl.readline(prompt) {
Ok(line) => {
if let Err(e) = rl.add_history_entry(line.as_str()) {
tracing::warn!("Failed to add history entry: {}", e);
}
match repl.process_line(&line) {
ProcessResult::Output(output) => {
if output.ends_with('\n') {
print!("{}", output);
} else {
println!("{}", output);
}
}
ProcessResult::Empty => {}
ProcessResult::Exit => {
save_history(&mut rl, &history_path);
return Ok(());
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continue;
}
Err(ReadlineError::Eof) => {
println!("^D");
break;
}
Err(err) => {
eprintln!("Error: {}", err);
break;
}
}
}
save_history(&mut rl, &history_path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string())
}
}
#[test]
fn trace_env_empty_yields_default_options() {
let opts = parse_trace_env(env_of(&[]));
assert!(opts.traceparent.is_none());
assert!(opts.tracestate.is_none());
assert!(opts.baggage.is_empty());
}
#[test]
fn trace_env_reads_traceparent_and_tracestate() {
let opts = parse_trace_env(env_of(&[
("TRACEPARENT", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
("TRACESTATE", "vendor=opaque"),
]));
assert_eq!(
opts.traceparent.as_deref(),
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
);
assert_eq!(opts.tracestate.as_deref(), Some("vendor=opaque"));
}
#[test]
fn trace_env_drops_tracestate_without_traceparent() {
let opts = parse_trace_env(env_of(&[("TRACESTATE", "vendor=opaque")]));
assert!(opts.traceparent.is_none());
assert!(opts.tracestate.is_none(), "tracestate alone is meaningless");
}
#[test]
fn trace_env_treats_empty_string_as_unset() {
let opts = parse_trace_env(env_of(&[("TRACEPARENT", ""), ("BAGGAGE", "")]));
assert!(opts.traceparent.is_none());
assert!(opts.baggage.is_empty());
}
#[test]
fn trace_env_parses_baggage() {
let opts = parse_trace_env(env_of(&[("BAGGAGE", "owner=atobey,tenant=acme")]));
assert_eq!(opts.baggage.get("owner").map(String::as_str), Some("atobey"));
assert_eq!(opts.baggage.get("tenant").map(String::as_str), Some("acme"));
}
#[test]
fn baggage_drops_properties_and_skips_malformed() {
let map = parse_w3c_baggage("owner=atobey;ttl=60 , broken , tenant = acme ");
assert_eq!(map.get("owner").map(String::as_str), Some("atobey"));
assert_eq!(map.get("tenant").map(String::as_str), Some("acme"));
assert!(!map.contains_key("broken"), "member without '=' is skipped");
assert_eq!(map.len(), 2);
}
#[test]
fn test_shell_words_simple() {
assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
}
#[test]
fn test_shell_words_semicolons() {
assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
}
#[test]
fn test_shell_words_quoted() {
assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
}
#[test]
fn test_shell_words_single_quoted() {
assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
}
#[test]
fn test_is_incomplete_if_block() {
let helper = make_test_helper();
assert!(helper.is_incomplete("if true; then"));
assert!(helper.is_incomplete("if true; then\n echo hello"));
assert!(!helper.is_incomplete("if true; then\n echo hello\nfi"));
}
#[test]
fn test_is_incomplete_for_loop() {
let helper = make_test_helper();
assert!(helper.is_incomplete("for x in 1 2 3; do"));
assert!(!helper.is_incomplete("for x in 1 2 3; do\n echo $x\ndone"));
}
#[test]
fn test_is_incomplete_unclosed_single_quote() {
let helper = make_test_helper();
assert!(helper.is_incomplete("echo 'hello"));
assert!(!helper.is_incomplete("echo 'hello'"));
}
#[test]
fn test_is_incomplete_unclosed_double_quote() {
let helper = make_test_helper();
assert!(helper.is_incomplete("echo \"hello"));
assert!(!helper.is_incomplete("echo \"hello\""));
}
#[test]
fn test_is_incomplete_backslash_continuation() {
let helper = make_test_helper();
assert!(helper.is_incomplete("echo hello \\"));
assert!(!helper.is_incomplete("echo hello"));
}
#[test]
fn test_is_incomplete_while_loop() {
let helper = make_test_helper();
assert!(helper.is_incomplete("while true; do"));
assert!(!helper.is_incomplete("while true; do\n echo loop\ndone"));
}
#[test]
fn test_is_incomplete_nested() {
let helper = make_test_helper();
assert!(helper.is_incomplete("if true; then\n for x in 1 2; do"));
assert!(helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done"));
assert!(!helper.is_incomplete("if true; then\n for x in 1 2; do\n echo $x\n done\nfi"));
}
#[test]
fn test_is_incomplete_empty() {
let helper = make_test_helper();
assert!(!helper.is_incomplete(""));
assert!(!helper.is_incomplete("echo hello"));
}
#[test]
fn test_is_incomplete_unterminated_heredoc() {
let helper = make_test_helper();
assert!(helper.is_incomplete("cat <<EOF"));
assert!(helper.is_incomplete("cat <<EOF\nhello"));
assert!(helper.is_incomplete("cat <<-DONE\n\thi"));
assert!(helper.is_incomplete("cat <<'EOF'\n$VAR"));
assert!(!helper.is_incomplete("cat <<EOF\nhello\nEOF"));
assert!(!helper.is_incomplete("cat <<-DONE\n\thi\n\tDONE"));
}
#[test]
fn test_detect_context_command_start() {
assert!(matches!(
detect_completion_context("", 0),
CompletionContext::Command
));
assert!(matches!(
detect_completion_context("ec", 2),
CompletionContext::Command
));
}
#[test]
fn test_detect_context_after_pipe() {
assert!(matches!(
detect_completion_context("echo hello | gr", 15),
CompletionContext::Command
));
}
#[test]
fn test_detect_context_variable() {
assert!(matches!(
detect_completion_context("echo $HO", 8),
CompletionContext::Variable
));
assert!(matches!(
detect_completion_context("echo ${HO", 9),
CompletionContext::Variable
));
}
#[test]
fn test_detect_context_path() {
assert!(matches!(
detect_completion_context("cat /etc/hos", 12),
CompletionContext::Path
));
}
#[test]
fn test_detect_context_command_substitution() {
assert!(matches!(
detect_completion_context("echo $(ca", 9),
CompletionContext::Command
));
assert!(matches!(
detect_completion_context("X=$(ec", 6),
CompletionContext::Command
));
}
#[test]
fn test_shell_words_comments() {
assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
}
#[test]
fn test_is_incomplete_comment_with_keyword() {
let helper = make_test_helper();
assert!(!helper.is_incomplete("# if this happens"));
assert!(!helper.is_incomplete("echo hello # if we do this"));
}
fn make_test_helper() -> KaishHelper {
let config = KernelConfig::transient();
let kernel = Kernel::new(config).expect("test kernel");
let client = EmbeddedClient::new(kernel);
let rt = Runtime::new().expect("test runtime");
KaishHelper::new(Box::new(client), rt.handle().clone())
}
#[test]
fn test_completion_through_client() {
let config = KernelConfig::transient();
let kernel = Kernel::new(config).expect("test kernel");
let client = EmbeddedClient::new(kernel);
let rt = Runtime::new().expect("test runtime");
rt.block_on(client.set_var("MYVAR", Value::String("hi".into())))
.expect("set_var failed");
let helper = KaishHelper::new(Box::new(client), rt.handle().clone());
let history = DefaultHistory::new();
let ctx = rustyline::Context::new(&history);
let (start, candidates) = helper.complete("ec", 2, &ctx).expect("command completion");
assert_eq!(start, 0);
assert!(
candidates.iter().any(|p| p.replacement == "echo"),
"expected `echo` among command candidates, got {:?}",
candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
);
let (start, candidates) = helper.complete("$MY", 3, &ctx).expect("variable completion");
assert_eq!(start, 0);
assert!(
candidates.iter().any(|p| p.replacement == "$MYVAR"),
"expected `$MYVAR` among variable candidates, got {:?}",
candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
);
}
}