mod constraint;
mod declaration;
mod error;
mod parse;
mod render;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use kaish_types::{ExecResult, ParamSchema, ToolArgs, ToolSchema, Value};
use kaish_tool_api::{IssueCode, ValidationIssue};
use crate::spawn::{
hermetic_env, spawn_process, OutputPolicy, SpawnContext, SpawnRequest, StdinPolicy,
};
use crate::tools::{virtual_cwd_error, ExecContext, Tool, ToolCtx};
pub use declaration::{find_executable, Flag, Positional, Stdin, Style, Tail, Verb, WrappedCommand};
pub use error::WrappedError;
use constraint::resolve_under;
use parse::Word;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PathCheck {
pub positional: String,
pub value: String,
pub root: PathBuf,
pub argv_index: usize,
pub resolved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderedCall {
pub verb: Option<String>,
pub argv: Vec<String>,
pub stdin: Stdin,
pub json_output: bool,
pub path_checks: Vec<PathCheck>,
}
#[derive(Debug, Clone)]
pub struct WrappedTool {
declaration: WrappedCommand,
executable: PathBuf,
}
impl WrappedTool {
pub(crate) fn from_parts(declaration: WrappedCommand, executable: PathBuf) -> Self {
Self {
declaration,
executable,
}
}
pub fn name(&self) -> &str {
&self.declaration.name
}
pub fn executable(&self) -> &Path {
&self.executable
}
pub fn env(&self) -> &BTreeMap<String, String> {
&self.declaration.env
}
pub fn declaration(&self) -> &WrappedCommand {
&self.declaration
}
pub fn schema(&self) -> ToolSchema {
let mut schema = ToolSchema::new(&self.declaration.name, self.root_description());
if let Some(root) = &self.declaration.root {
for param in verb_params(root) {
schema = schema.param(param);
}
for (label, command) in &root.examples {
schema = schema.example(label, command);
}
}
for (label, command) in &self.declaration.examples {
schema = schema.example(label, command);
}
for verb in &self.declaration.verbs {
schema = schema.subcommand(verb_schema(verb));
}
schema = schema.with_raw_argv();
if self.every_verb_is_json() {
schema = schema.with_typed_substitution();
}
schema
}
fn root_description(&self) -> String {
match &self.declaration.root {
Some(root) if root.tail == Tail::Forward => {
append_clause(&self.declaration.about, "forwards undeclared flags")
}
_ => self.declaration.about.clone(),
}
}
fn every_verb_is_json(&self) -> bool {
let mut verbs = self
.declaration
.root
.iter()
.chain(self.declaration.verbs.iter())
.peekable();
verbs.peek().is_some() && verbs.all(|verb| verb.json_output)
}
pub fn plan_call(&self, args: &ToolArgs) -> Result<RenderedCall, WrappedError> {
let words = self.execution_words(args)?;
let call = parse::parse(&self.declaration, &words).map_err(|failure| failure.error)?;
let Some(verb) = call.verb(&self.declaration) else {
return Err(WrappedError::MissingVerb {
command: self.declaration.name.clone(),
allowed: parse::allowed_verbs(&self.declaration),
});
};
if let Some(error) = constraint::check(&self.declaration, verb, &call).into_iter().next() {
return Err(error);
}
let rendered = render::render(&self.declaration, verb, &call);
let mut argv = rendered.argv;
let mut path_checks = constraint::path_checks(verb, &call, &rendered.item_argv_index);
for check in &mut path_checks {
if !Path::new(&check.value).is_absolute() {
continue;
}
let resolved = resolve_under(&check.value, Path::new("/"), &check.root)
.map_err(|e| e.attributed_to(&self.declaration.name, &check.positional))?;
if let Some(word) = argv.get_mut(check.argv_index) {
*word = resolved.to_string_lossy().into_owned();
}
check.resolved = true;
}
Ok(RenderedCall {
verb: verb.name.clone(),
argv,
stdin: verb.stdin,
json_output: verb.json_output,
path_checks,
})
}
pub fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
let words: Vec<Word> = args
.positional
.iter()
.map(|value| Word::from_validation_text(crate::interpreter::value_to_string(value)))
.collect();
let call = match parse::parse(&self.declaration, &words) {
Ok(call) => call,
Err(failure) => return vec![issue(&failure.error, failure.uncertain)],
};
let Some(verb) = call.verb(&self.declaration) else {
return Vec::new();
};
let mut issues: Vec<ValidationIssue> = constraint::check(&self.declaration, verb, &call)
.iter()
.map(|error| issue(error, call.uncertain))
.collect();
let rendered = render::render(&self.declaration, verb, &call);
for check in constraint::path_checks(verb, &call, &rendered.item_argv_index) {
if !Path::new(&check.value).is_absolute() {
continue;
}
if let Err(error) = resolve_under(&check.value, Path::new("/"), &check.root) {
issues.push(issue(
&error.attributed_to(&self.declaration.name, &check.positional),
call.uncertain,
));
}
}
issues
}
pub fn resolve_path_check(
&self,
check: &PathCheck,
real_cwd: &Path,
) -> Result<PathBuf, WrappedError> {
resolve_under(&check.value, real_cwd, &check.root)
.map_err(|e| e.attributed_to(&self.declaration.name, &check.positional))
}
fn execution_words(&self, args: &ToolArgs) -> Result<Vec<Word>, WrappedError> {
let mut words = Vec::with_capacity(args.positional.len());
for (offset, value) in args.positional.iter().enumerate() {
let position = offset + 1;
if let Value::Bytes(bytes) = value {
return Err(WrappedError::BinaryArgument {
command: self.declaration.name.clone(),
position,
byte_len: bytes.len(),
});
}
let text = crate::interpreter::value_to_string(value);
if text.contains('\0') {
return Err(WrappedError::NulByte {
command: self.declaration.name.clone(),
position,
});
}
words.push(Word::literal(text));
}
Ok(words)
}
}
#[async_trait]
impl Tool for WrappedTool {
fn name(&self) -> &str {
WrappedTool::name(self)
}
fn schema(&self) -> ToolSchema {
WrappedTool::schema(self)
}
fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
WrappedTool::validate(self, args)
}
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");
};
self.run(args, ctx).await
}
}
impl WrappedTool {
async fn run(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
let call = match self.plan_call(&args) {
Ok(call) => call,
Err(error) => return ExecResult::failure(error.exit_code(), error.to_string()),
};
let label = match &call.verb {
Some(verb) => format!("{} {verb}", self.declaration.name),
None => self.declaration.name.clone(),
};
let Some(real_cwd) = ctx.backend.resolve_real_path(&ctx.cwd) else {
return virtual_cwd_error(&self.declaration.name, &ctx.cwd);
};
let mut argv = call.argv;
for check in &call.path_checks {
if check.resolved {
continue;
}
match self.resolve_path_check(check, &real_cwd) {
Ok(resolved) => {
if let Some(word) = argv.get_mut(check.argv_index) {
*word = resolved.to_string_lossy().into_owned();
}
}
Err(error) => return ExecResult::failure(error.exit_code(), error.to_string()),
}
}
let mut env = match hermetic_env(&ctx.scope) {
Ok(env) => env,
Err(e) => return ExecResult::failure(1, format!("{label}: {e}")),
};
env.retain(|(name, _)| !self.declaration.env.contains_key(name));
env.extend(
self.declaration
.env
.iter()
.map(|(name, value)| (name.clone(), value.clone())),
);
let stdin = match call.stdin {
Stdin::Closed => {
if ctx.pipe_stdin.is_some() || ctx.stdin.is_some() {
return ExecResult::failure(2, format!("{label}: does not read stdin"));
}
StdinPolicy::Null
}
Stdin::Pipe => {
let pipe = ctx.pipe_stdin.take();
let prefix = ctx.take_stdin();
match (prefix, pipe) {
(None, None) => StdinPolicy::Null,
(prefix, pipe) => StdinPolicy::Piped { prefix, pipe },
}
}
};
let spawn_ctx = SpawnContext::from_exec_context(ctx);
let request = SpawnRequest {
executable: self.executable.clone(),
argv,
cwd: real_cwd,
output: OutputPolicy::Captured,
env,
stdin,
label: label.clone(),
};
let result = spawn_process(request, &spawn_ctx).await;
if call.json_output {
return bind_json_output(result, &label);
}
result
}
}
fn bind_json_output(mut result: ExecResult, label: &str) -> ExecResult {
if !result.ok() || result.did_spill {
return result;
}
let text = match result.try_text_out() {
Ok(text) => text.into_owned(),
Err(e) => return ExecResult::failure(1, format!("{label}: declared JSON output, but {e}")),
};
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => {
result.data = Some(kaish_types::json_to_value_no_envelope(json));
result.data_is_value = true;
result
}
Err(e) => ExecResult::failure(
1,
format!("{label}: declared JSON output, but stdout does not parse: {e}"),
),
}
}
fn issue(error: &WrappedError, uncertain: bool) -> ValidationIssue {
let code = match error {
WrappedError::UnknownFlag { .. }
| WrappedError::ClusteredShort { .. }
| WrappedError::GluedShortValue { .. }
| WrappedError::UnexpectedFlagValue { .. }
| WrappedError::RepeatedFlag { .. } => IssueCode::UnknownFlag,
WrappedError::MissingFlagValue { .. }
| WrappedError::MissingRequiredFlag { .. }
| WrappedError::MissingRequiredPositional { .. } => IssueCode::MissingRequiredArg,
WrappedError::NotAnInteger { .. } | WrappedError::NotInChoices { .. } => {
IssueCode::InvalidArgType
}
_ => IssueCode::WrappedCallRejected,
};
let issue = if uncertain {
ValidationIssue::warning(code, error.to_string())
} else {
ValidationIssue::error(code, error.to_string())
};
if error.command().is_empty() {
issue
} else {
issue.with_command(error.command().to_string())
}
}
fn verb_schema(verb: &Verb) -> ToolSchema {
let description = match verb.tail {
Tail::Forward => append_clause(&verb.about, "forwards undeclared flags"),
_ => verb.about.clone(),
};
let mut schema = ToolSchema::new(verb.name_or_root(), description);
for param in verb_params(verb) {
schema = schema.param(param);
}
for (label, command) in &verb.examples {
schema = schema.example(label, command);
}
if verb.json_output {
schema = schema.with_typed_substitution();
}
schema
}
fn verb_params(verb: &Verb) -> Vec<ParamSchema> {
let mut params = Vec::with_capacity(verb.flags.len() + verb.positionals.len());
for flag in &verb.flags {
let param_type = if !flag.takes_value {
"bool"
} else if flag.int {
"int"
} else {
"string"
};
let mut description = flag.about.clone();
if !flag.choices.is_empty() {
description = append_clause(&description, &format!("one of: {}", flag.choices.join(", ")));
}
let mut param = ParamSchema::new(&flag.name, param_type)
.with_required(flag.required)
.with_description(description)
.with_aliases(flag.aliases.clone())
.with_repeatable(flag.repeatable);
if !flag.takes_value {
param = param.with_default(Some(Value::Bool(false)));
}
params.push(param);
}
for positional in &verb.positionals {
let mut description = positional.about.clone();
if let Some(root) = &positional.path_under {
description = append_clause(&description, &format!("must be under {}", root.display()));
}
params.push(
ParamSchema::new(&positional.name, "string")
.with_required(positional.required)
.with_description(description)
.positional(),
);
}
params
}
fn append_clause(description: &str, clause: &str) -> String {
if description.is_empty() {
clause.to_string()
} else {
format!("{description}; {clause}")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;