use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use std::collections::HashMap;
use std::path::Path;
use crate::ast::ToolDef;
use crate::interpreter::{ExecResult, OutputData};
use crate::parser::parse;
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
use crate::validator::{Severity, Validator};
pub struct Validate;
#[derive(Parser, Debug)]
#[command(name = "kaish-validate", about = "Validate kaish scripts without executing")]
struct ValidateArgs {
#[arg(id = "expr", short = 'e', long = "expr")]
_expr: Option<String>,
#[arg(id = "quiet", short = 'q', long = "quiet")]
_quiet: bool,
#[arg(id = "warnings", short = 'w', long = "warnings")]
_warnings: bool,
#[command(flatten)]
global: GlobalFlags,
path: Vec<String>,
}
#[async_trait]
impl Tool for Validate {
fn name(&self) -> &str {
"kaish-validate"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&ValidateArgs::command(),
"kaish-validate",
"Validate kaish scripts without executing",
[
("Validate a script file", "kaish-validate script.kai"),
("Validate inline code", "kaish-validate -e 'grep \"[\" file.txt'"),
("Check exit code only", "kaish-validate -q script.kai && echo 'valid'"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match ValidateArgs::try_parse_from(
std::iter::once("kaish-validate".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("kaish-validate: {e}")),
};
parsed.global.apply(ctx);
let registry = match &ctx.tools {
Some(r) => r.clone(),
None => return ExecResult::failure(1, "kaish-validate: tool registry not available"),
};
let quiet = args.has_flag("quiet") || args.has_flag("q");
let show_warnings = args.has_flag("warnings") || args.has_flag("w") || !args.flags.contains("warnings");
let (source, label) = if let Some(expr) = args.get_string("expr", usize::MAX) {
(expr, "<expr>".to_string())
} else if let Some(path) = args.get_string("path", 0) {
let resolved = ctx.resolve_path(&path);
match ctx.backend.read(Path::new(&resolved), None).await {
Ok(data) => match String::from_utf8(data) {
Ok(content) => (content, path),
Err(_) => return ExecResult::failure(1, format!("kaish-validate: {}: invalid UTF-8", path)),
},
Err(e) => return ExecResult::failure(1, format!("kaish-validate: {}: {}", path, e)),
}
} else if let Some(stdin) = &ctx.stdin {
(stdin.clone(), "<stdin>".to_string())
} else {
return ExecResult::failure(1, "kaish-validate: no input provided (use path or -e)");
};
let program = match parse(&source) {
Ok(p) => p,
Err(errors) => {
if quiet {
return ExecResult::failure(2, "");
}
let msg = errors
.iter()
.map(|e| format!("{}: parse error: {}", label, e))
.collect::<Vec<_>>()
.join("\n");
return ExecResult::failure(2, msg);
}
};
let user_tools: HashMap<String, ToolDef> = HashMap::new();
let validator = Validator::new(®istry, &user_tools);
let issues = validator.validate(&program);
let (errors, warnings): (Vec<_>, Vec<_>) = issues
.into_iter()
.partition(|i| i.severity == Severity::Error);
if quiet {
if errors.is_empty() {
return ExecResult::success("");
} else {
return ExecResult::failure(1, "");
}
}
let mut output = String::new();
for error in &errors {
output.push_str(&format!("{}: {}\n", label, error.format(&source)));
}
if show_warnings {
for warning in &warnings {
output.push_str(&format!("{}: {}\n", label, warning.format(&source)));
}
}
if errors.is_empty() && (warnings.is_empty() || !show_warnings) {
if !quiet {
output = format!("{}: valid\n", label);
}
ExecResult::with_output(OutputData::text(output))
} else if errors.is_empty() {
ExecResult::with_output(OutputData::text(output))
} else {
ExecResult::failure(1, output)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::tools::{register_builtins, ToolRegistry};
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
vfs.mount("/", mem);
let vfs = Arc::new(vfs);
let mut registry = ToolRegistry::new();
register_builtins(&mut registry);
let registry = Arc::new(registry);
ExecContext::with_vfs_and_tools(vfs, registry)
}
#[tokio::test]
async fn test_validate_valid_expr() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.named.insert("expr".to_string(), Value::String("echo hello".into()));
let result = Validate.execute(args, &mut ctx).await;
assert!(result.ok(), "expected success: {}", result.err);
}
#[tokio::test]
async fn test_validate_invalid_regex() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.named.insert("expr".to_string(), Value::String("grep '[' file.txt".into()));
let result = Validate.execute(args, &mut ctx).await;
assert!(!result.ok() || result.err.contains("regex") || result.text_out().contains("regex"));
}
#[tokio::test]
async fn test_validate_break_outside_loop() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.named.insert("expr".to_string(), Value::String("break".into()));
let result = Validate.execute(args, &mut ctx).await;
assert!(!result.ok(), "break outside loop should fail validation");
assert!(result.err.contains("loop") || result.text_out().contains("loop"));
}
#[tokio::test]
async fn test_validate_quiet_mode() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.named.insert("expr".to_string(), Value::String("echo hello".into()));
args.flags.insert("q".to_string());
let result = Validate.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().is_empty(), "quiet mode should have no output");
}
}