1use std::collections::{HashMap, HashSet};
4
5use crate::ast::{
6 Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
7 PipelineStage, Program, SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment,
8 WhileLoop,
9 Value,
10};
11use crate::kernel::{bind_glued_short_value, push_repeatable_value};
12use crate::scheduler::{is_bool_type, schema_param_lookup};
13use crate::validator::issue::Span;
14use crate::tools::{
15 global_flag_value_is_truthy, is_global_output_flag, ArgBinding, ToolArgs, ToolRegistry,
16 ToolSchema,
17};
18use kaish_types::CommandKind;
19
20use super::issue::{IssueCode, ValidationIssue};
21#[cfg(test)]
22use super::issue::Severity;
23use super::scope_tracker::ScopeTracker;
24
25pub struct Validator<'a> {
27 registry: &'a ToolRegistry,
29 user_tools: &'a HashMap<String, ToolDef>,
31 catalog: &'a [ToolSchema],
49 scope: ScopeTracker,
51 loop_depth: usize,
53 function_depth: usize,
55 issues: Vec<ValidationIssue>,
57}
58
59impl<'a> Validator<'a> {
60 pub fn new(
65 registry: &'a ToolRegistry,
66 user_tools: &'a HashMap<String, ToolDef>,
67 catalog: &'a [ToolSchema],
68 ) -> Self {
69 Self {
70 registry,
71 user_tools,
72 catalog,
73 scope: ScopeTracker::new(),
74 loop_depth: 0,
75 function_depth: 0,
76 issues: Vec::new(),
77 }
78 }
79
80 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
82 for stmt in &program.statements {
83 self.validate_stmt(stmt);
84 }
85 self.issues
86 }
87
88 fn validate_stmt(&mut self, stmt: &Stmt) {
90 match stmt {
91 Stmt::Assignment(assign) => self.validate_assignment(assign),
92 Stmt::Command(cmd) => self.validate_command(cmd),
93 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
94 Stmt::If(if_stmt) => self.validate_if(if_stmt),
95 Stmt::For(for_loop) => self.validate_for(for_loop),
96 Stmt::While(while_loop) => self.validate_while(while_loop),
97 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
98 Stmt::Break(levels) => self.validate_break(*levels),
99 Stmt::Continue(levels) => self.validate_continue(*levels),
100 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
101 Stmt::Exit(expr) => {
102 if let Some(e) = expr {
103 self.validate_expr(e);
104 }
105 }
106 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
107 Stmt::Test(test_expr) => self.validate_test(test_expr),
108 Stmt::Arith(_) => {}
111 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
112 self.validate_stmt(left);
113 self.validate_stmt(right);
114 }
115 Stmt::EnvScoped { assignments, body } => {
116 for assign in assignments {
119 self.validate_assignment(assign);
120 }
121 self.validate_stmt(body);
122 }
123 Stmt::Empty => {}
124 }
125 }
126
127 fn validate_assignment(&mut self, assign: &Assignment) {
137 self.validate_expr(&assign.value);
139
140 let name = assign.name();
141
142 if let Err(bad) = crate::name::validate(name) {
148 if !matches!(bad.ch, '.' | '#') {
152 self.issues.push(ValidationIssue::error(
153 IssueCode::InvisibleAssignmentTarget,
154 bad.to_string(),
155 ));
156 }
157 }
158
159 if let Some(mixed) = crate::name::mixed_script(name) {
163 self.issues.push(
164 ValidationIssue::warning(IssueCode::MixedScriptName, mixed.to_string())
165 .with_suggestion(mixed.suggestion()),
166 );
167 }
168
169 if assign.path.segments.len() == 1 {
170 if let Some(dot) = name.find('.') {
171 let (root, rest) = (&name[..dot], &name[dot + 1..]);
172 self.issues.push(
173 ValidationIssue::error(
174 IssueCode::DottedAssignmentTarget,
175 format!(
176 "'{name}' is not a valid assignment target — kaish uses bracket \
177 access, not dots"
178 ),
179 )
180 .with_suggestion(format!("use `{root}[{rest}]=value`")),
181 );
182 }
183 if name.contains('#') {
184 self.issues.push(
189 ValidationIssue::error(
190 IssueCode::UnreadableAssignmentTarget,
191 format!(
192 "'{name}' is not a valid assignment target — a variable name \
193 cannot contain `#`"
194 ),
195 )
196 .with_suggestion(format!(
197 "drop the `#`, e.g. `{}=value`",
198 name.replace('#', "_")
199 )),
200 );
201 }
202 self.scope.bind(name);
204 } else if !self.scope.is_bound(name) {
205 self.issues.push(
206 ValidationIssue::error(
207 IssueCode::LvalueUndefinedRoot,
208 format!(
209 "'{name}' is not defined — a subscripted assignment never creates the \
210 root variable"
211 ),
212 )
213 .with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
214 );
215 self.scope.bind(name);
218 }
219 }
220
221 fn validate_command(&mut self, cmd: &Command) {
223 if cmd.name == "source" || cmd.name == "." {
225 return;
226 }
227
228 if !is_static_command_name(&cmd.name) {
230 return;
231 }
232
233 let is_builtin = self.registry.contains(&cmd.name);
235 let is_user_tool = self.user_tools.contains_key(&cmd.name);
236 let is_special = is_special_command(&cmd.name);
237
238 if !is_builtin && !is_user_tool && !is_special {
239 self.issues.push(ValidationIssue::warning(
243 IssueCode::UndefinedCommand,
244 format!("command '{}' not found in builtin registry", cmd.name),
245 )
246 .with_suggestion("this may be a script in PATH or external command")
247 .with_command(cmd.name.clone()));
248 }
249
250 for arg in &cmd.args {
252 self.validate_arg(arg);
253 }
254
255 if let Some(tool) = self.registry.get(&cmd.name) {
260 let owned;
268 let schema: &ToolSchema =
269 match self.catalog.binary_search_by(|s| s.name.as_str().cmp(cmd.name.as_str())) {
270 Ok(i) => &self.catalog[i],
271 Err(_) => {
272 owned = tool.schema();
273 &owned
274 }
275 };
276 debug_assert_eq!(
291 schema.name, cmd.name,
292 "schema-driven validation issues for '{}' would carry the wrong command \
293 name — schema.name ('{}') must match the command actually invoked",
294 cmd.name, schema.name
295 );
296 let tool_args = build_tool_args_for_validation(&cmd.args, Some(schema));
297 let tool_issues = tool.validate(&tool_args);
298 self.issues.extend(tool_issues);
299 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
300 self.validate_user_tool_args(user_tool, &cmd.args);
302 }
303
304 for redirect in &cmd.redirects {
306 self.validate_expr(&redirect.target);
307 }
308 }
309
310 fn validate_arg(&mut self, arg: &Arg) {
312 match arg {
313 Arg::Positional(expr) => self.validate_expr(expr),
314 Arg::Named { value, .. } => self.validate_expr(value),
315 Arg::WordAssign { value, .. } => self.validate_expr(value),
316 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
317 }
318 }
319
320 fn validate_pipeline(&mut self, pipe: &Pipeline) {
322 let scatter_stage =
327 pipe.stages.iter().filter_map(|s| s.as_command()).find(|c| c.name == "scatter");
328 let has_gather =
329 pipe.stages.iter().filter_map(|s| s.as_command()).any(|c| c.name == "gather");
330 if let Some(scatter_cmd) = scatter_stage
331 && !has_gather
332 {
333 self.issues.push(
334 ValidationIssue::error(
335 IssueCode::ScatterWithoutGather,
336 "scatter without gather — parallel results would be lost",
337 )
338 .with_suggestion("add gather: ... | scatter | cmd | gather")
339 .with_command(scatter_cmd.name.clone()),
340 );
341 }
342
343 for stage in &pipe.stages {
344 match stage {
345 PipelineStage::Command(cmd) => self.validate_command(cmd),
346 PipelineStage::Compound(stmt) => self.validate_stmt(stmt),
347 }
348 }
349 }
350
351 fn validate_if(&mut self, if_stmt: &IfStmt) {
353 self.validate_expr(&if_stmt.condition);
354
355 self.scope.push_frame();
356 for stmt in &if_stmt.then_branch {
357 self.validate_stmt(stmt);
358 }
359 self.scope.pop_frame();
360
361 if let Some(else_branch) = &if_stmt.else_branch {
362 self.scope.push_frame();
363 for stmt in else_branch {
364 self.validate_stmt(stmt);
365 }
366 self.scope.pop_frame();
367 }
368 }
369
370 fn validate_for(&mut self, for_loop: &ForLoop) {
372 for item in &for_loop.items {
374 self.validate_expr(item);
375
376 if self.is_bare_scalar_var(item) {
379 self.issues.push(
380 ValidationIssue::error(
381 IssueCode::ForLoopScalarVar,
382 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
383 )
384 .with_suggestion(concat!(
385 "wrap it in $(...) — for a collection use keys/values:\n",
386 " for x in $(values $coll) # list elements / record values\n",
387 " for k in $(keys $coll) # list indices / record keys\n",
388 " for i in $(split \"$VAR\") # split a string on whitespace\n",
389 " for i in $(split \"$VAR\" \":\") # split a string on a delimiter\n",
390 " for i in $(seq 1 10) # iterate numbers\n",
391 " for i in $(glob \"*.rs\") # iterate files",
392 )),
393 );
394 }
395 }
396
397 self.loop_depth += 1;
398 self.scope.push_frame();
399
400 if let Some(mixed) = crate::name::mixed_script(&for_loop.variable) {
405 self.issues.push(
406 ValidationIssue::warning(IssueCode::MixedScriptName, mixed.to_string())
407 .with_suggestion(mixed.suggestion()),
408 );
409 }
410
411 self.scope.bind(&for_loop.variable);
413
414 for stmt in &for_loop.body {
415 self.validate_stmt(stmt);
416 }
417
418 self.scope.pop_frame();
419 self.loop_depth -= 1;
420 }
421
422 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
428 match expr {
429 Expr::VarRef(_) => true,
431 Expr::VarWithDefault { .. } => true,
433 Expr::CommandSubst(_) => false,
435 Expr::Literal(_) => false,
437 Expr::Interpolated(_) => false,
439 _ => false,
441 }
442 }
443
444 fn validate_while(&mut self, while_loop: &WhileLoop) {
446 self.validate_expr(&while_loop.condition);
447
448 self.loop_depth += 1;
449 self.scope.push_frame();
450
451 for stmt in &while_loop.body {
452 self.validate_stmt(stmt);
453 }
454
455 self.scope.pop_frame();
456 self.loop_depth -= 1;
457 }
458
459 fn validate_case(&mut self, case_stmt: &CaseStmt) {
461 self.validate_expr(&case_stmt.expr);
462
463 for branch in &case_stmt.branches {
464 self.validate_case_branch(branch);
465 }
466 }
467
468 fn validate_case_branch(&mut self, branch: &CaseBranch) {
470 self.scope.push_frame();
471 for stmt in &branch.body {
472 self.validate_stmt(stmt);
473 }
474 self.scope.pop_frame();
475 }
476
477 fn validate_break(&mut self, levels: Option<usize>) {
479 if self.loop_depth == 0 {
480 self.issues.push(ValidationIssue::error(
481 IssueCode::BreakOutsideLoop,
482 "break used outside of a loop",
483 ));
484 } else if let Some(n) = levels
485 && n > self.loop_depth {
486 self.issues.push(ValidationIssue::warning(
487 IssueCode::BreakOutsideLoop,
488 format!(
489 "break {} exceeds loop nesting depth {}",
490 n, self.loop_depth
491 ),
492 ));
493 }
494 }
495
496 fn validate_continue(&mut self, levels: Option<usize>) {
498 if self.loop_depth == 0 {
499 self.issues.push(ValidationIssue::error(
500 IssueCode::BreakOutsideLoop,
501 "continue used outside of a loop",
502 ));
503 } else if let Some(n) = levels
504 && n > self.loop_depth {
505 self.issues.push(ValidationIssue::warning(
506 IssueCode::BreakOutsideLoop,
507 format!(
508 "continue {} exceeds loop nesting depth {}",
509 n, self.loop_depth
510 ),
511 ));
512 }
513 }
514
515 fn validate_return(&mut self, expr: Option<&Expr>) {
517 if let Some(e) = expr {
518 self.validate_expr(e);
519 }
520
521 if self.function_depth == 0 {
522 self.issues.push(ValidationIssue::error(
523 IssueCode::ReturnOutsideFunction,
524 "return used outside of a function",
525 ));
526 }
527 }
528
529 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
531 self.function_depth += 1;
532 self.scope.push_frame();
533
534 for param in &tool_def.params {
536 self.scope.bind(¶m.name);
537 if let Some(default) = ¶m.default {
539 self.validate_expr(default);
540 }
541 }
542
543 for stmt in &tool_def.body {
545 self.validate_stmt(stmt);
546 }
547
548 self.scope.pop_frame();
549 self.function_depth -= 1;
550 }
551
552 fn validate_test(&mut self, test: &TestExpr) {
554 match test {
555 TestExpr::FileTest { path, .. } => self.validate_expr(path),
556 TestExpr::StringTest { value, .. } => self.validate_expr(value),
557 TestExpr::Comparison { left, right, .. } => {
558 self.validate_expr(left);
559 self.validate_expr(right);
560 }
561 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
562 self.validate_test(left);
563 self.validate_test(right);
564 }
565 TestExpr::Not { expr } => self.validate_test(expr),
566 TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
567 self.validate_expr(left);
568 self.validate_expr(right);
569 }
570 }
571 }
572
573 fn validate_expr(&mut self, expr: &Expr) {
575 match expr {
576 Expr::Not(inner) => self.validate_expr(inner),
577 Expr::Literal(_) => {}
578 Expr::NumericLiteral { .. } => {}
579 Expr::VarRef(path) => self.validate_var_ref(path),
580 Expr::Interpolated(parts) => {
581 for part in parts {
582 self.validate_string_part(part);
583 }
584 }
585 Expr::HereDocBody { parts, .. } => {
586 for sp in parts {
587 self.validate_spanned_string_part(sp);
588 }
589 }
590 Expr::BinaryOp { left, right, .. } => {
591 self.validate_expr(left);
592 self.validate_expr(right);
593 }
594 Expr::CommandSubst(stmts) => {
595 for stmt in stmts {
596 self.validate_stmt(stmt);
597 }
598 }
599 Expr::Test(test) => self.validate_test(test),
600 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
601 Expr::VarLength(path) => {
602 if let Some(VarSegment::Field(root)) = path.segments.first() {
603 self.check_var_defined(root);
604 }
605 }
606 Expr::VarWithDefault { .. } => {
607 }
609 Expr::Arithmetic(_) | Expr::Arith(_) => {
610 }
612 Expr::Command(cmd) => self.validate_command(cmd),
613 Expr::LastExitCode | Expr::CurrentPid => {}
614 Expr::GlobPattern(_) => {}
615 Expr::ListLiteral(elems) => {
616 for elem in elems {
617 match elem {
618 ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
619 }
620 }
621 }
622 Expr::RecordLiteral(entries) => {
623 for entry in entries {
624 self.validate_expr(&entry.value);
625 }
626 }
627 }
628 }
629
630 fn validate_var_ref(&mut self, path: &VarPath) {
632 if let Some(VarSegment::Field(name)) = path.segments.first() {
633 if name == "?" && path.segments.len() > 1 {
636 self.issues.push(
637 ValidationIssue::error(
638 IssueCode::LastResultFieldAccess,
639 "${?.field} is removed; $? is the POSIX exit code",
640 )
641 .with_suggestion(
642 "use `kaish-last` to read the previous command's data or stdout",
643 ),
644 );
645 return;
646 }
647 self.check_var_defined(name);
648 }
649 }
650
651 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
655 let issues_before = self.issues.len();
656 self.validate_string_part(&sp.part);
657 let span = Span::new(sp.offset, sp.offset + sp.len);
658 for issue in &mut self.issues[issues_before..] {
659 if issue.span.is_none() {
660 issue.span = Some(span);
661 }
662 }
663 }
664
665 fn validate_string_part(&mut self, part: &StringPart) {
667 match part {
668 StringPart::Literal(_) => {}
669 StringPart::Var(path) => self.validate_var_ref(path),
670 StringPart::VarWithDefault { default, .. } => {
671 for p in default {
673 self.validate_string_part(p);
674 }
675 }
676 StringPart::VarLength(path) => {
677 if let Some(VarSegment::Field(root)) = path.segments.first() {
678 self.check_var_defined(root);
679 }
680 }
681 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
682 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
684 for stmt in stmts {
685 self.validate_stmt(stmt);
686 }
687 }
688 StringPart::LastExitCode | StringPart::CurrentPid => {}
689 }
690 }
691
692 fn check_var_defined(&mut self, name: &str) {
694 if ScopeTracker::should_skip_undefined_check(name) {
696 return;
697 }
698
699 if !self.scope.is_bound(name) {
700 self.issues.push(ValidationIssue::warning(
701 IssueCode::PossiblyUndefinedVariable,
702 format!("variable '{}' may be undefined", name),
703 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
704 }
705 }
706
707 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
715 let positional_count = args
716 .iter()
717 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
718 .count();
719
720 let required_count = tool_def
721 .params
722 .iter()
723 .filter(|p| p.default.is_none())
724 .count();
725
726 if positional_count < required_count {
727 self.issues.push(ValidationIssue::error(
728 IssueCode::MissingRequiredArg,
729 format!(
730 "'{}' requires {} arguments, got {}",
731 tool_def.name, required_count, positional_count
732 ),
733 )
734 .with_command(tool_def.name.clone()));
735 }
736 }
737}
738
739pub(crate) fn is_static_command_name(name: &str) -> bool {
746 !name.starts_with('$') && !name.contains("$(") && !name.contains("${")
747}
748
749#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768pub(crate) enum SpecialForm {
769 True,
771 False,
773 Source,
775}
776
777impl SpecialForm {
778 pub(crate) fn from_name(name: &str) -> Option<Self> {
781 match name {
782 "true" | ":" => Some(Self::True),
788 "false" => Some(Self::False),
789 "source" | "." => Some(Self::Source),
790 _ => None,
791 }
792 }
793}
794
795pub(crate) fn is_runtime_special_form(name: &str) -> bool {
797 SpecialForm::from_name(name).is_some()
798}
799
800pub(crate) fn classify_command_name(
804 name: &str,
805 is_builtin: bool,
806 is_user_tool: bool,
807) -> CommandKind {
808 if !is_static_command_name(name) {
809 return CommandKind::Dynamic;
810 }
811 if is_runtime_special_form(name) {
812 return CommandKind::Special;
813 }
814 if is_user_tool {
817 return CommandKind::UserTool;
818 }
819 if is_builtin {
820 return CommandKind::Builtin;
821 }
822 CommandKind::External
823}
824
825fn is_special_command(name: &str) -> bool {
827 matches!(name, "true" | "false" | "readonly" | "local")
832}
833
834pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
839 let mut tool_args = ToolArgs::new();
840
841 if schema.is_some_and(|s| s.raw_argv) {
850 for arg in args {
854 match arg {
855 Arg::Positional(expr) => tool_args.positional.push(expr_to_placeholder(expr)),
856 Arg::ShortFlag(name) => {
857 tool_args.positional.push(Value::String(format!("-{name}")))
858 }
859 Arg::LongFlag(name) => {
860 tool_args.positional.push(Value::String(format!("--{name}")))
861 }
862 Arg::Named { key, value } => tool_args.positional.push(Value::String(format!(
863 "--{key}={}",
864 crate::interpreter::value_to_string(&expr_to_placeholder(value))
865 ))),
866 Arg::WordAssign { key, value } => tool_args.positional.push(Value::String(
867 format!(
868 "{key}={}",
869 crate::interpreter::value_to_string(&expr_to_placeholder(value))
870 ),
871 )),
872 Arg::DoubleDash => {
873 tool_args.positional.push(Value::String("--".to_string()));
874 }
875 }
876 }
877 return tool_args;
878 }
879
880 if schema.is_some_and(|s| matches!(s.arg_binding, ArgBinding::Verbatim)) {
884 let lift_global_flags = !schema.is_some_and(|s| s.owns_output);
889 let mut words = Vec::new();
890 let mut past_double_dash = false;
891 for arg in args {
892 match arg {
893 Arg::Positional(expr) => words.push(expr_to_placeholder(expr)),
894 Arg::ShortFlag(name) => words.push(Value::String(format!("-{name}"))),
895 Arg::LongFlag(name) => {
896 if lift_global_flags && !past_double_dash && is_global_output_flag(name) {
897 tool_args.flags.insert(name.clone());
898 } else {
899 words.push(Value::String(format!("--{name}")));
900 }
901 }
902 Arg::Named { key, value } => {
903 if lift_global_flags && !past_double_dash && is_global_output_flag(key) {
904 if global_flag_value_is_truthy(&expr_to_placeholder(value)) {
910 tool_args.flags.insert(key.clone());
911 }
912 } else {
913 words.push(Value::String(format!("--{key}=<value>")));
914 }
915 }
916 Arg::WordAssign { key, .. } => {
917 words.push(Value::String(format!("{key}=<value>")));
918 }
919 Arg::DoubleDash => {
920 past_double_dash = true;
921 words.push(Value::String("--".to_string()));
922 }
923 }
924 }
925 tool_args.words = Some(words);
926 return tool_args;
927 }
928
929 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
933 let mut consumed: HashSet<usize> = HashSet::new();
934 let mut past_double_dash = false;
935
936 for i in 0..args.len() {
937 match &args[i] {
938 Arg::DoubleDash => past_double_dash = true,
939 Arg::Positional(expr) => {
940 if !consumed.contains(&i) {
941 tool_args.positional.push(expr_to_placeholder(expr));
942 }
943 }
944 Arg::Named { key, value } => {
945 let v = expr_to_placeholder(value);
946 if past_double_dash {
948 tool_args
949 .positional
950 .push(Value::String(format!("--{key}={}", crate::interpreter::value_to_string(&v))));
951 continue;
952 }
953 if !past_double_dash && is_global_output_flag(key) {
960 if global_flag_value_is_truthy(&v) {
961 tool_args.flags.insert(key.clone());
962 }
963 continue;
964 }
965 match param_lookup.get(key.as_str()) {
966 Some(&(canonical, _, _, true)) => {
968 let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
969 }
970 Some(&(canonical, ..)) => {
971 tool_args.named.insert(canonical.to_string(), v);
972 }
973 None => {
974 tool_args.named.insert(key.clone(), v);
975 }
976 }
977 }
978 Arg::WordAssign { key, value } => {
979 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
983 }
984 Arg::ShortFlag(name) => {
985 if past_double_dash {
986 tool_args.positional.push(Value::String(format!("-{name}")));
987 } else {
988 bind_short_flag_for_validation(
989 name,
990 ¶m_lookup,
991 args,
992 i,
993 &mut consumed,
994 &mut tool_args,
995 );
996 }
997 }
998 Arg::LongFlag(name) => {
999 if past_double_dash {
1000 tool_args.positional.push(Value::String(format!("--{name}")));
1001 } else {
1002 match param_lookup.get(name.as_str()) {
1003 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
1004 bind_value_or_flag(
1005 &mut tool_args, name, canonical, consumes, repeatable, args, i,
1006 &mut consumed,
1007 );
1008 }
1009 Some(&(canonical, ..)) => {
1010 tool_args.flags.insert(canonical.to_string());
1011 }
1012 None => {
1013 tool_args.flags.insert(name.clone());
1014 }
1015 }
1016 }
1017 }
1018 }
1019 }
1020
1021 tool_args
1022}
1023
1024fn bind_short_flag_for_validation(
1030 name: &str,
1031 param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
1032 args: &[Arg],
1033 i: usize,
1034 consumed: &mut HashSet<usize>,
1035 tool_args: &mut ToolArgs,
1036) {
1037 if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
1039 if is_bool_type(typ) {
1040 tool_args.flags.insert(canonical.to_string());
1041 } else {
1042 bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
1043 }
1044 return;
1045 }
1046 if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
1048 .get(&name[..1])
1049 .filter(|(_, typ, ..)| !is_bool_type(typ))
1050 {
1051 let glued = name[1..].to_string();
1052 if glued.is_empty() {
1053 bind_value_or_flag(
1054 tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
1055 );
1056 } else {
1057 let _ =
1058 bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
1059 }
1060 return;
1061 }
1062 let bytes = name.as_bytes();
1065 let mut p = 0;
1066 while p < bytes.len() {
1067 let key = &name[p..p + 1];
1068 match param_lookup.get(key) {
1069 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
1070 let glued = name[p + 1..].to_string();
1071 if glued.is_empty() {
1072 bind_value_or_flag(
1073 tool_args, key, canonical, consumes, repeatable, args, i, consumed,
1074 );
1075 } else {
1076 let _ = bind_glued_short_value(
1077 tool_args, key, canonical, consumes, repeatable, glued,
1078 );
1079 }
1080 return;
1081 }
1082 _ => {
1083 tool_args.flags.insert(key.to_string());
1084 p += 1;
1085 }
1086 }
1087 }
1088}
1089
1090#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
1099 tool_args: &mut ToolArgs,
1100 flag_name: &str,
1101 canonical: &str,
1102 consumes: usize,
1103 repeatable: bool,
1104 args: &[Arg],
1105 i: usize,
1106 consumed: &mut HashSet<usize>,
1107) {
1108 let want = consumes.max(1);
1109 let allow_word_assign = consumes <= 1;
1110 let mut collected: Vec<Value> = Vec::with_capacity(want);
1111 for _ in 0..want {
1112 let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
1113 let idx = i + 1 + off;
1114 if consumed.contains(&idx) {
1115 return None;
1116 }
1117 match a {
1118 Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
1119 Arg::WordAssign { key, value } if allow_word_assign => {
1120 let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
1121 Some((idx, Value::String(format!("{key}={s}"))))
1122 }
1123 _ => None,
1124 }
1125 });
1126 match found {
1127 Some((idx, v)) => {
1128 consumed.insert(idx);
1129 collected.push(v);
1130 }
1131 None => break,
1132 }
1133 }
1134
1135 if collected.is_empty() {
1136 tool_args.flags.insert(canonical.to_string());
1137 return;
1138 }
1139 if consumes <= 1 {
1140 if let Some(v) = collected.into_iter().next() {
1141 if repeatable {
1142 let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
1153 } else {
1154 tool_args.named.insert(canonical.to_string(), v);
1155 }
1156 }
1157 return;
1158 }
1159 let occ: Vec<serde_json::Value> = collected
1165 .iter()
1166 .map(crate::interpreter::value_to_json)
1167 .collect();
1168 let entry = tool_args
1169 .named
1170 .entry(canonical.to_string())
1171 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
1172 if let Value::Json(serde_json::Value::Array(outer)) = entry {
1173 outer.push(serde_json::Value::Array(occ));
1174 }
1175}
1176
1177fn expr_to_placeholder(expr: &Expr) -> Value {
1182 match expr {
1183 Expr::Literal(val) => val.clone(),
1184 Expr::NumericLiteral { value, .. } => value.clone(),
1191 Expr::Interpolated(parts) if parts.len() == 1 => {
1192 if let StringPart::Literal(s) = &parts[0] {
1193 Value::String(s.clone())
1194 } else {
1195 Value::String("<dynamic>".to_string())
1196 }
1197 }
1198 _ => Value::String("<dynamic>".to_string()),
1200 }
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use super::*;
1206 use crate::tools::{register_builtins, ToolRegistry};
1207
1208 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
1209 let mut registry = ToolRegistry::new();
1210 register_builtins(&mut registry);
1211 let user_tools = HashMap::new();
1212 (registry, user_tools)
1213 }
1214
1215 #[test]
1221 fn validation_binds_json_value_the_way_execution_does() {
1222 let schema = ToolSchema::new("probe", "probe");
1223 let named = |v: Value| {
1224 vec![Arg::Named { key: "json".to_string(), value: Expr::Literal(v) }]
1225 };
1226
1227 for on in [Value::Int(1), Value::String("yes".into()), Value::Bool(true)] {
1228 let args = build_tool_args_for_validation(&named(on.clone()), Some(&schema));
1229 assert!(args.flags.contains("json"), "{on:?} should bind --json on");
1230 assert!(!args.named.contains_key("json"), "{on:?} must not reach named");
1231 }
1232
1233 for off in [Value::Int(0), Value::String("0".into()), Value::Bool(false)] {
1234 let args = build_tool_args_for_validation(&named(off.clone()), Some(&schema));
1235 assert!(!args.flags.contains("json"), "{off:?} should bind --json off");
1236 assert!(!args.named.contains_key("json"), "{off:?} must not reach named");
1237 }
1238 }
1239
1240 #[test]
1245 fn validation_binds_dynamic_json_value_as_on() {
1246 let schema = ToolSchema::new("probe", "probe");
1247 let args = build_tool_args_for_validation(
1248 &[Arg::Named {
1249 key: "json".to_string(),
1250 value: Expr::VarRef(VarPath::simple("MODE")),
1251 }],
1252 Some(&schema),
1253 );
1254 assert!(args.flags.contains("json"));
1255 }
1256
1257 #[test]
1262 fn validation_keeps_json_after_double_dash_out_of_flags() {
1263 let schema = ToolSchema::new("probe", "probe");
1264 let args = build_tool_args_for_validation(
1265 &[
1266 Arg::DoubleDash,
1267 Arg::Named {
1268 key: "json".to_string(),
1269 value: Expr::Literal(Value::Bool(true)),
1270 },
1271 ],
1272 Some(&schema),
1273 );
1274 assert!(!args.flags.contains("json"), "flags: {:?}", args.flags);
1275 }
1276
1277 #[test]
1278 fn validates_undefined_command() {
1279 let (registry, user_tools) = make_validator();
1280 let validator = Validator::new(®istry, &user_tools, &[]);
1281
1282 let program = Program {
1283 statements: vec![Stmt::Command(Command {
1284 name: "nonexistent_command".to_string(),
1285 args: vec![],
1286 redirects: vec![],
1287 })],
1288 };
1289
1290 let issues = validator.validate(&program);
1291 assert!(!issues.is_empty());
1292 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1293 assert!(
1299 issues.iter().any(|i| i.code == IssueCode::UndefinedCommand
1300 && i.command.as_deref() == Some("nonexistent_command")),
1301 "UndefinedCommand must carry the unresolved name: {:?}",
1302 issues
1303 );
1304 }
1305
1306 #[test]
1310 fn test_command_is_a_known_builtin() {
1311 let (registry, user_tools) = make_validator();
1312 let validator = Validator::new(®istry, &user_tools, &[]);
1313
1314 let program = Program {
1315 statements: vec![Stmt::Command(Command {
1316 name: "test".to_string(),
1317 args: vec![
1318 Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
1319 Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
1320 ],
1321 redirects: vec![],
1322 })],
1323 };
1324
1325 let issues = validator.validate(&program);
1326 assert!(
1327 !issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
1328 "`test` is a builtin — no undefined-command warning: {issues:?}"
1329 );
1330 }
1331
1332 #[test]
1333 fn validates_known_command() {
1334 let (registry, user_tools) = make_validator();
1335 let validator = Validator::new(®istry, &user_tools, &[]);
1336
1337 let program = Program {
1338 statements: vec![Stmt::Command(Command {
1339 name: "echo".to_string(),
1340 args: vec![Arg::Positional(Expr::Literal(Value::String(
1341 "hello".to_string(),
1342 )))],
1343 redirects: vec![],
1344 })],
1345 };
1346
1347 let issues = validator.validate(&program);
1348 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1350 }
1351
1352 #[test]
1378 fn catalog_hit_and_fallback_produce_identical_schema_driven_issues() {
1379 let (registry, user_tools) = make_validator();
1380 let catalog = registry.schemas();
1381 assert!(
1382 catalog.binary_search_by(|s| s.name.as_str().cmp("jq")).is_ok(),
1383 "fixture assumption: `jq` must be in the catalog for this to be a real hit"
1384 );
1385
1386 let program = Program {
1387 statements: vec![Stmt::Command(Command {
1388 name: "jq".to_string(),
1389 args: vec![],
1390 redirects: vec![],
1391 })],
1392 };
1393
1394 let fallback_issues =
1395 Validator::new(®istry, &user_tools, &[]).validate(&program);
1396 let catalog_issues =
1397 Validator::new(®istry, &user_tools, &catalog).validate(&program);
1398
1399 assert!(
1402 fallback_issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1403 "test input should trip MissingRequiredArg (`filter`) via the fallback path; \
1404 got {fallback_issues:?}"
1405 );
1406
1407 type Rendered<'a> = (Severity, IssueCode, &'a str, Option<&'a str>, Option<&'a str>);
1413 fn render(issues: &[ValidationIssue]) -> Vec<Rendered<'_>> {
1414 issues
1415 .iter()
1416 .map(|i| {
1417 (
1418 i.severity,
1419 i.code,
1420 i.message.as_str(),
1421 i.suggestion.as_deref(),
1422 i.command.as_deref(),
1423 )
1424 })
1425 .collect()
1426 }
1427 assert_eq!(
1428 render(&fallback_issues),
1429 render(&catalog_issues),
1430 "catalog-hit and tool.schema()-fallback validation must agree exactly \
1431 (same codes, same messages, same order); fallback={fallback_issues:?} \
1432 catalog={catalog_issues:?}"
1433 );
1434 }
1435
1436 #[test]
1437 fn glued_value_flags_dont_false_error_at_validation() {
1438 let (registry, user_tools) = make_validator();
1444 let validator = Validator::new(®istry, &user_tools, &[]);
1445
1446 let program = Program {
1447 statements: vec![Stmt::Command(Command {
1448 name: "sed".to_string(),
1449 args: vec![
1450 Arg::ShortFlag("e1d".to_string()),
1451 Arg::ShortFlag("e2d".to_string()),
1452 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1453 ],
1454 redirects: vec![],
1455 })],
1456 };
1457
1458 let issues = validator.validate(&program);
1459 assert!(
1460 !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
1461 "glued -e flags false-errored at validation: {:?}",
1462 issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1463 );
1464 }
1465
1466 #[test]
1467 fn validates_break_outside_loop() {
1468 let (registry, user_tools) = make_validator();
1469 let validator = Validator::new(®istry, &user_tools, &[]);
1470
1471 let program = Program {
1472 statements: vec![Stmt::Break(None)],
1473 };
1474
1475 let issues = validator.validate(&program);
1476 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1477 }
1478
1479 #[test]
1480 fn validates_break_inside_loop() {
1481 let (registry, user_tools) = make_validator();
1482 let validator = Validator::new(®istry, &user_tools, &[]);
1483
1484 let program = Program {
1485 statements: vec![Stmt::For(ForLoop {
1486 variable: "i".to_string(),
1487 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
1488 body: vec![Stmt::Break(None)],
1489 })],
1490 };
1491
1492 let issues = validator.validate(&program);
1493 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1495 }
1496
1497 #[test]
1498 fn validates_undefined_variable() {
1499 let (registry, user_tools) = make_validator();
1500 let validator = Validator::new(®istry, &user_tools, &[]);
1501
1502 let program = Program {
1503 statements: vec![Stmt::Command(Command {
1504 name: "echo".to_string(),
1505 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
1506 "UNDEFINED_VAR",
1507 )))],
1508 redirects: vec![],
1509 })],
1510 };
1511
1512 let issues = validator.validate(&program);
1513 assert!(issues
1514 .iter()
1515 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1516 }
1517
1518 #[test]
1519 fn validates_defined_variable() {
1520 let (registry, user_tools) = make_validator();
1521 let validator = Validator::new(®istry, &user_tools, &[]);
1522
1523 let program = Program {
1524 statements: vec![
1525 Stmt::Assignment(Assignment {
1527 path: VarPath::simple("MY_VAR"),
1528 value: Expr::Literal(Value::String("value".to_string())),
1529 local: false,
1530 }),
1531 Stmt::Command(Command {
1533 name: "echo".to_string(),
1534 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
1535 redirects: vec![],
1536 }),
1537 ],
1538 };
1539
1540 let issues = validator.validate(&program);
1541 assert!(!issues
1543 .iter()
1544 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1545 && i.message.contains("MY_VAR")));
1546 }
1547
1548 #[test]
1549 fn skips_underscore_prefixed_vars() {
1550 let (registry, user_tools) = make_validator();
1551 let validator = Validator::new(®istry, &user_tools, &[]);
1552
1553 let program = Program {
1554 statements: vec![Stmt::Command(Command {
1555 name: "echo".to_string(),
1556 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1557 redirects: vec![],
1558 })],
1559 };
1560
1561 let issues = validator.validate(&program);
1562 assert!(!issues
1564 .iter()
1565 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1566 }
1567
1568 #[test]
1569 fn builtin_vars_are_defined() {
1570 let (registry, user_tools) = make_validator();
1571 let validator = Validator::new(®istry, &user_tools, &[]);
1572
1573 let program = Program {
1574 statements: vec![Stmt::Command(Command {
1575 name: "echo".to_string(),
1576 args: vec![
1577 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1578 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1579 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1580 ],
1581 redirects: vec![],
1582 })],
1583 };
1584
1585 let issues = validator.validate(&program);
1586 assert!(!issues
1588 .iter()
1589 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1590 }
1591
1592 #[test]
1593 fn validates_scatter_without_gather() {
1594 let (registry, user_tools) = make_validator();
1595 let validator = Validator::new(®istry, &user_tools, &[]);
1596
1597 let program = Program {
1598 statements: vec![Stmt::Pipeline(Pipeline {
1599 stages: vec![
1600 Command { name: "seq".to_string(), args: vec![
1601 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1602 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1603 ], redirects: vec![] },
1604 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1605 Command { name: "echo".to_string(), args: vec![
1606 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1607 ], redirects: vec![] },
1608 ]
1609 .into_iter()
1610 .map(PipelineStage::Command)
1611 .collect(),
1612 background: false,
1613 })],
1614 };
1615
1616 let issues = validator.validate(&program);
1617 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1618 "should flag scatter without gather: {:?}", issues);
1619 assert!(
1622 issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather
1623 && i.command.as_deref() == Some("scatter")),
1624 "ScatterWithoutGather must carry the scatter stage's own name: {:?}",
1625 issues
1626 );
1627 }
1628
1629 #[test]
1630 fn allows_scatter_with_gather() {
1631 let (registry, user_tools) = make_validator();
1632 let validator = Validator::new(®istry, &user_tools, &[]);
1633
1634 let program = Program {
1635 statements: vec![Stmt::Pipeline(Pipeline {
1636 stages: vec![
1637 Command { name: "seq".to_string(), args: vec![
1638 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1639 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1640 ], redirects: vec![] },
1641 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1642 Command { name: "echo".to_string(), args: vec![
1643 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1644 ], redirects: vec![] },
1645 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1646 ]
1647 .into_iter()
1648 .map(PipelineStage::Command)
1649 .collect(),
1650 background: false,
1651 })],
1652 };
1653
1654 let issues = validator.validate(&program);
1655 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1656 "scatter with gather should pass: {:?}", issues);
1657 }
1658
1659 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1660 let mut user_tools = HashMap::new();
1661 user_tools.insert(
1662 "mytool".to_string(),
1663 ToolDef {
1664 name: "mytool".to_string(),
1665 params: vec![crate::ast::ParamDef {
1666 name: "input".to_string(),
1667 param_type: None,
1668 default: None,
1669 }],
1670 body: vec![],
1671 },
1672 );
1673 user_tools
1674 }
1675
1676 #[test]
1680 fn user_tool_wordassign_counts_as_positional() {
1681 let mut registry = ToolRegistry::new();
1682 register_builtins(&mut registry);
1683 let user_tools = make_user_tool_with_required_positional();
1684 let validator = Validator::new(®istry, &user_tools, &[]);
1685
1686 let program = Program {
1687 statements: vec![Stmt::Command(Command {
1688 name: "mytool".to_string(),
1689 args: vec![Arg::WordAssign {
1690 key: "foo".to_string(),
1691 value: Expr::Literal(Value::String("bar".to_string())),
1692 }],
1693 redirects: vec![],
1694 })],
1695 };
1696
1697 let issues = validator.validate(&program);
1698 assert!(
1699 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1700 "WordAssign should satisfy required positional; got {:?}",
1701 issues
1702 );
1703 }
1704
1705 #[test]
1708 fn user_tool_no_args_still_errors() {
1709 let mut registry = ToolRegistry::new();
1710 register_builtins(&mut registry);
1711 let user_tools = make_user_tool_with_required_positional();
1712 let validator = Validator::new(®istry, &user_tools, &[]);
1713
1714 let program = Program {
1715 statements: vec![Stmt::Command(Command {
1716 name: "mytool".to_string(),
1717 args: vec![],
1718 redirects: vec![],
1719 })],
1720 };
1721
1722 let issues = validator.validate(&program);
1723 assert!(
1724 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1725 "missing positional should still error; got {:?}",
1726 issues
1727 );
1728 assert!(
1738 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg
1739 && i.command.as_deref() == Some("mytool")),
1740 "user-tool MissingRequiredArg must carry the tool's own name: {:?}",
1741 issues
1742 );
1743 }
1744}