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],
40 scope: ScopeTracker,
42 loop_depth: usize,
44 function_depth: usize,
46 issues: Vec<ValidationIssue>,
48}
49
50impl<'a> Validator<'a> {
51 pub fn new(
56 registry: &'a ToolRegistry,
57 user_tools: &'a HashMap<String, ToolDef>,
58 catalog: &'a [ToolSchema],
59 ) -> Self {
60 Self {
61 registry,
62 user_tools,
63 catalog,
64 scope: ScopeTracker::new(),
65 loop_depth: 0,
66 function_depth: 0,
67 issues: Vec::new(),
68 }
69 }
70
71 pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
73 for stmt in &program.statements {
74 self.validate_stmt(stmt);
75 }
76 self.issues
77 }
78
79 fn validate_stmt(&mut self, stmt: &Stmt) {
81 match stmt {
82 Stmt::Assignment(assign) => self.validate_assignment(assign),
83 Stmt::Command(cmd) => self.validate_command(cmd),
84 Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
85 Stmt::If(if_stmt) => self.validate_if(if_stmt),
86 Stmt::For(for_loop) => self.validate_for(for_loop),
87 Stmt::While(while_loop) => self.validate_while(while_loop),
88 Stmt::Case(case_stmt) => self.validate_case(case_stmt),
89 Stmt::Break(levels) => self.validate_break(*levels),
90 Stmt::Continue(levels) => self.validate_continue(*levels),
91 Stmt::Return(expr) => self.validate_return(expr.as_deref()),
92 Stmt::Exit(expr) => {
93 if let Some(e) = expr {
94 self.validate_expr(e);
95 }
96 }
97 Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
98 Stmt::Test(test_expr) => self.validate_test(test_expr),
99 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
100 self.validate_stmt(left);
101 self.validate_stmt(right);
102 }
103 Stmt::EnvScoped { assignments, body } => {
104 for assign in assignments {
107 self.validate_assignment(assign);
108 }
109 self.validate_stmt(body);
110 }
111 Stmt::Empty => {}
112 }
113 }
114
115 fn validate_assignment(&mut self, assign: &Assignment) {
125 self.validate_expr(&assign.value);
127
128 let name = assign.name();
129
130 if let Err(bad) = crate::name::validate(name) {
136 if !matches!(bad.ch, '.' | '#') {
140 self.issues.push(ValidationIssue::error(
141 IssueCode::InvisibleAssignmentTarget,
142 bad.to_string(),
143 ));
144 }
145 }
146
147 if let Some(mixed) = crate::name::mixed_script(name) {
151 self.issues.push(
152 ValidationIssue::warning(IssueCode::MixedScriptName, mixed.to_string())
153 .with_suggestion(mixed.suggestion()),
154 );
155 }
156
157 if assign.path.segments.len() == 1 {
158 if let Some(dot) = name.find('.') {
159 let (root, rest) = (&name[..dot], &name[dot + 1..]);
160 self.issues.push(
161 ValidationIssue::error(
162 IssueCode::DottedAssignmentTarget,
163 format!(
164 "'{name}' is not a valid assignment target — kaish uses bracket \
165 access, not dots"
166 ),
167 )
168 .with_suggestion(format!("use `{root}[{rest}]=value`")),
169 );
170 }
171 if name.contains('#') {
172 self.issues.push(
177 ValidationIssue::error(
178 IssueCode::UnreadableAssignmentTarget,
179 format!(
180 "'{name}' is not a valid assignment target — a variable name \
181 cannot contain `#`"
182 ),
183 )
184 .with_suggestion(format!(
185 "drop the `#`, e.g. `{}=value`",
186 name.replace('#', "_")
187 )),
188 );
189 }
190 self.scope.bind(name);
192 } else if !self.scope.is_bound(name) {
193 self.issues.push(
194 ValidationIssue::error(
195 IssueCode::LvalueUndefinedRoot,
196 format!(
197 "'{name}' is not defined — a subscripted assignment never creates the \
198 root variable"
199 ),
200 )
201 .with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
202 );
203 self.scope.bind(name);
206 }
207 }
208
209 fn validate_command(&mut self, cmd: &Command) {
211 if cmd.name == "source" || cmd.name == "." {
213 return;
214 }
215
216 if !is_static_command_name(&cmd.name) {
218 return;
219 }
220
221 let is_builtin = self.registry.contains(&cmd.name);
223 let is_user_tool = self.user_tools.contains_key(&cmd.name);
224 let is_special = is_special_command(&cmd.name);
225
226 if !is_builtin && !is_user_tool && !is_special {
227 self.issues.push(ValidationIssue::warning(
231 IssueCode::UndefinedCommand,
232 format!("command '{}' not found in builtin registry", cmd.name),
233 ).with_suggestion("this may be a script in PATH or external command"));
234 }
235
236 for arg in &cmd.args {
238 self.validate_arg(arg);
239 }
240
241 if let Some(tool) = self.registry.get(&cmd.name) {
246 let owned;
254 let schema: &ToolSchema =
255 match self.catalog.binary_search_by(|s| s.name.as_str().cmp(cmd.name.as_str())) {
256 Ok(i) => &self.catalog[i],
257 Err(_) => {
258 owned = tool.schema();
259 &owned
260 }
261 };
262 let tool_args = build_tool_args_for_validation(&cmd.args, Some(schema));
263 let tool_issues = tool.validate(&tool_args);
264 self.issues.extend(tool_issues);
265 } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
266 self.validate_user_tool_args(user_tool, &cmd.args);
268 }
269
270 for redirect in &cmd.redirects {
272 self.validate_expr(&redirect.target);
273 }
274 }
275
276 fn validate_arg(&mut self, arg: &Arg) {
278 match arg {
279 Arg::Positional(expr) => self.validate_expr(expr),
280 Arg::Named { value, .. } => self.validate_expr(value),
281 Arg::WordAssign { value, .. } => self.validate_expr(value),
282 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
283 }
284 }
285
286 fn validate_pipeline(&mut self, pipe: &Pipeline) {
288 let named = |name: &str| {
290 pipe.stages
291 .iter()
292 .filter_map(|s| s.as_command())
293 .any(|c| c.name == name)
294 };
295 let has_scatter = named("scatter");
296 let has_gather = named("gather");
297 if has_scatter && !has_gather {
298 self.issues.push(
299 ValidationIssue::error(
300 IssueCode::ScatterWithoutGather,
301 "scatter without gather — parallel results would be lost",
302 ).with_suggestion("add gather: ... | scatter | cmd | gather")
303 );
304 }
305
306 for stage in &pipe.stages {
307 match stage {
308 PipelineStage::Command(cmd) => self.validate_command(cmd),
309 PipelineStage::Compound(stmt) => self.validate_stmt(stmt),
310 }
311 }
312 }
313
314 fn validate_if(&mut self, if_stmt: &IfStmt) {
316 self.validate_expr(&if_stmt.condition);
317
318 self.scope.push_frame();
319 for stmt in &if_stmt.then_branch {
320 self.validate_stmt(stmt);
321 }
322 self.scope.pop_frame();
323
324 if let Some(else_branch) = &if_stmt.else_branch {
325 self.scope.push_frame();
326 for stmt in else_branch {
327 self.validate_stmt(stmt);
328 }
329 self.scope.pop_frame();
330 }
331 }
332
333 fn validate_for(&mut self, for_loop: &ForLoop) {
335 for item in &for_loop.items {
337 self.validate_expr(item);
338
339 if self.is_bare_scalar_var(item) {
342 self.issues.push(
343 ValidationIssue::error(
344 IssueCode::ForLoopScalarVar,
345 "bare variable in for loop iterates once (kaish has no implicit word splitting)",
346 )
347 .with_suggestion(concat!(
348 "wrap it in $(...) — for a collection use keys/values:\n",
349 " for x in $(values $coll) # list elements / record values\n",
350 " for k in $(keys $coll) # list indices / record keys\n",
351 " for i in $(split \"$VAR\") # split a string on whitespace\n",
352 " for i in $(split \"$VAR\" \":\") # split a string on a delimiter\n",
353 " for i in $(seq 1 10) # iterate numbers\n",
354 " for i in $(glob \"*.rs\") # iterate files",
355 )),
356 );
357 }
358 }
359
360 self.loop_depth += 1;
361 self.scope.push_frame();
362
363 if let Some(mixed) = crate::name::mixed_script(&for_loop.variable) {
368 self.issues.push(
369 ValidationIssue::warning(IssueCode::MixedScriptName, mixed.to_string())
370 .with_suggestion(mixed.suggestion()),
371 );
372 }
373
374 self.scope.bind(&for_loop.variable);
376
377 for stmt in &for_loop.body {
378 self.validate_stmt(stmt);
379 }
380
381 self.scope.pop_frame();
382 self.loop_depth -= 1;
383 }
384
385 fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
391 match expr {
392 Expr::VarRef(_) => true,
394 Expr::VarWithDefault { .. } => true,
396 Expr::CommandSubst(_) => false,
398 Expr::Literal(_) => false,
400 Expr::Interpolated(_) => false,
402 _ => false,
404 }
405 }
406
407 fn validate_while(&mut self, while_loop: &WhileLoop) {
409 self.validate_expr(&while_loop.condition);
410
411 self.loop_depth += 1;
412 self.scope.push_frame();
413
414 for stmt in &while_loop.body {
415 self.validate_stmt(stmt);
416 }
417
418 self.scope.pop_frame();
419 self.loop_depth -= 1;
420 }
421
422 fn validate_case(&mut self, case_stmt: &CaseStmt) {
424 self.validate_expr(&case_stmt.expr);
425
426 for branch in &case_stmt.branches {
427 self.validate_case_branch(branch);
428 }
429 }
430
431 fn validate_case_branch(&mut self, branch: &CaseBranch) {
433 self.scope.push_frame();
434 for stmt in &branch.body {
435 self.validate_stmt(stmt);
436 }
437 self.scope.pop_frame();
438 }
439
440 fn validate_break(&mut self, levels: Option<usize>) {
442 if self.loop_depth == 0 {
443 self.issues.push(ValidationIssue::error(
444 IssueCode::BreakOutsideLoop,
445 "break used outside of a loop",
446 ));
447 } else if let Some(n) = levels
448 && n > self.loop_depth {
449 self.issues.push(ValidationIssue::warning(
450 IssueCode::BreakOutsideLoop,
451 format!(
452 "break {} exceeds loop nesting depth {}",
453 n, self.loop_depth
454 ),
455 ));
456 }
457 }
458
459 fn validate_continue(&mut self, levels: Option<usize>) {
461 if self.loop_depth == 0 {
462 self.issues.push(ValidationIssue::error(
463 IssueCode::BreakOutsideLoop,
464 "continue used outside of a loop",
465 ));
466 } else if let Some(n) = levels
467 && n > self.loop_depth {
468 self.issues.push(ValidationIssue::warning(
469 IssueCode::BreakOutsideLoop,
470 format!(
471 "continue {} exceeds loop nesting depth {}",
472 n, self.loop_depth
473 ),
474 ));
475 }
476 }
477
478 fn validate_return(&mut self, expr: Option<&Expr>) {
480 if let Some(e) = expr {
481 self.validate_expr(e);
482 }
483
484 if self.function_depth == 0 {
485 self.issues.push(ValidationIssue::error(
486 IssueCode::ReturnOutsideFunction,
487 "return used outside of a function",
488 ));
489 }
490 }
491
492 fn validate_tool_def(&mut self, tool_def: &ToolDef) {
494 self.function_depth += 1;
495 self.scope.push_frame();
496
497 for param in &tool_def.params {
499 self.scope.bind(¶m.name);
500 if let Some(default) = ¶m.default {
502 self.validate_expr(default);
503 }
504 }
505
506 for stmt in &tool_def.body {
508 self.validate_stmt(stmt);
509 }
510
511 self.scope.pop_frame();
512 self.function_depth -= 1;
513 }
514
515 fn validate_test(&mut self, test: &TestExpr) {
517 match test {
518 TestExpr::FileTest { path, .. } => self.validate_expr(path),
519 TestExpr::StringTest { value, .. } => self.validate_expr(value),
520 TestExpr::Comparison { left, right, .. } => {
521 self.validate_expr(left);
522 self.validate_expr(right);
523 }
524 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
525 self.validate_test(left);
526 self.validate_test(right);
527 }
528 TestExpr::Not { expr } => self.validate_test(expr),
529 TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
530 self.validate_expr(left);
531 self.validate_expr(right);
532 }
533 }
534 }
535
536 fn validate_expr(&mut self, expr: &Expr) {
538 match expr {
539 Expr::Not(inner) => self.validate_expr(inner),
540 Expr::Literal(_) => {}
541 Expr::VarRef(path) => self.validate_var_ref(path),
542 Expr::Interpolated(parts) => {
543 for part in parts {
544 self.validate_string_part(part);
545 }
546 }
547 Expr::HereDocBody { parts, .. } => {
548 for sp in parts {
549 self.validate_spanned_string_part(sp);
550 }
551 }
552 Expr::BinaryOp { left, right, .. } => {
553 self.validate_expr(left);
554 self.validate_expr(right);
555 }
556 Expr::CommandSubst(stmts) => {
557 for stmt in stmts {
558 self.validate_stmt(stmt);
559 }
560 }
561 Expr::Test(test) => self.validate_test(test),
562 Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
563 Expr::VarLength(path) => {
564 if let Some(VarSegment::Field(root)) = path.segments.first() {
565 self.check_var_defined(root);
566 }
567 }
568 Expr::VarWithDefault { .. } => {
569 }
571 Expr::Arithmetic(_) => {
572 }
574 Expr::Command(cmd) => self.validate_command(cmd),
575 Expr::LastExitCode | Expr::CurrentPid => {}
576 Expr::GlobPattern(_) => {}
577 Expr::ListLiteral(elems) => {
578 for elem in elems {
579 match elem {
580 ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
581 }
582 }
583 }
584 Expr::RecordLiteral(entries) => {
585 for entry in entries {
586 self.validate_expr(&entry.value);
587 }
588 }
589 }
590 }
591
592 fn validate_var_ref(&mut self, path: &VarPath) {
594 if let Some(VarSegment::Field(name)) = path.segments.first() {
595 if name == "?" && path.segments.len() > 1 {
598 self.issues.push(
599 ValidationIssue::error(
600 IssueCode::LastResultFieldAccess,
601 "${?.field} is removed; $? is the POSIX exit code",
602 )
603 .with_suggestion(
604 "use `kaish-last` to read the previous command's data or stdout",
605 ),
606 );
607 return;
608 }
609 self.check_var_defined(name);
610 }
611 }
612
613 fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
617 let issues_before = self.issues.len();
618 self.validate_string_part(&sp.part);
619 let span = Span::new(sp.offset, sp.offset + sp.len);
620 for issue in &mut self.issues[issues_before..] {
621 if issue.span.is_none() {
622 issue.span = Some(span);
623 }
624 }
625 }
626
627 fn validate_string_part(&mut self, part: &StringPart) {
629 match part {
630 StringPart::Literal(_) => {}
631 StringPart::Var(path) => self.validate_var_ref(path),
632 StringPart::VarWithDefault { default, .. } => {
633 for p in default {
635 self.validate_string_part(p);
636 }
637 }
638 StringPart::VarLength(path) => {
639 if let Some(VarSegment::Field(root)) = path.segments.first() {
640 self.check_var_defined(root);
641 }
642 }
643 StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
644 StringPart::Arithmetic(_) => {} StringPart::CommandSubst(stmts) => {
646 for stmt in stmts {
647 self.validate_stmt(stmt);
648 }
649 }
650 StringPart::LastExitCode | StringPart::CurrentPid => {}
651 }
652 }
653
654 fn check_var_defined(&mut self, name: &str) {
656 if ScopeTracker::should_skip_undefined_check(name) {
658 return;
659 }
660
661 if !self.scope.is_bound(name) {
662 self.issues.push(ValidationIssue::warning(
663 IssueCode::PossiblyUndefinedVariable,
664 format!("variable '{}' may be undefined", name),
665 ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
666 }
667 }
668
669 fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
677 let positional_count = args
678 .iter()
679 .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
680 .count();
681
682 let required_count = tool_def
683 .params
684 .iter()
685 .filter(|p| p.default.is_none())
686 .count();
687
688 if positional_count < required_count {
689 self.issues.push(ValidationIssue::error(
690 IssueCode::MissingRequiredArg,
691 format!(
692 "'{}' requires {} arguments, got {}",
693 tool_def.name, required_count, positional_count
694 ),
695 ));
696 }
697 }
698}
699
700pub(crate) fn is_static_command_name(name: &str) -> bool {
707 !name.starts_with('$') && !name.contains("$(") && !name.contains("${")
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub(crate) enum SpecialForm {
730 True,
732 False,
734 Source,
736}
737
738impl SpecialForm {
739 pub(crate) fn from_name(name: &str) -> Option<Self> {
742 match name {
743 "true" | ":" => Some(Self::True),
749 "false" => Some(Self::False),
750 "source" | "." => Some(Self::Source),
751 _ => None,
752 }
753 }
754}
755
756pub(crate) fn is_runtime_special_form(name: &str) -> bool {
758 SpecialForm::from_name(name).is_some()
759}
760
761pub(crate) fn classify_command_name(
765 name: &str,
766 is_builtin: bool,
767 is_user_tool: bool,
768) -> CommandKind {
769 if !is_static_command_name(name) {
770 return CommandKind::Dynamic;
771 }
772 if is_runtime_special_form(name) {
773 return CommandKind::Special;
774 }
775 if is_user_tool {
778 return CommandKind::UserTool;
779 }
780 if is_builtin {
781 return CommandKind::Builtin;
782 }
783 CommandKind::External
784}
785
786fn is_special_command(name: &str) -> bool {
788 matches!(name, "true" | "false" | "readonly" | "local")
793}
794
795pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
800 let mut tool_args = ToolArgs::new();
801
802 if schema.is_some_and(|s| s.raw_argv) {
811 for arg in args {
815 match arg {
816 Arg::Positional(expr) => tool_args.positional.push(expr_to_placeholder(expr)),
817 Arg::ShortFlag(name) => {
818 tool_args.positional.push(Value::String(format!("-{name}")))
819 }
820 Arg::LongFlag(name) => {
821 tool_args.positional.push(Value::String(format!("--{name}")))
822 }
823 Arg::Named { key, value } => tool_args.positional.push(Value::String(format!(
824 "--{key}={}",
825 crate::interpreter::value_to_string(&expr_to_placeholder(value))
826 ))),
827 Arg::WordAssign { key, value } => tool_args.positional.push(Value::String(
828 format!(
829 "{key}={}",
830 crate::interpreter::value_to_string(&expr_to_placeholder(value))
831 ),
832 )),
833 Arg::DoubleDash => {
834 tool_args.positional.push(Value::String("--".to_string()));
835 }
836 }
837 }
838 return tool_args;
839 }
840
841 if schema.is_some_and(|s| matches!(s.arg_binding, ArgBinding::Verbatim)) {
845 let lift_global_flags = !schema.is_some_and(|s| s.owns_output);
850 let mut words = Vec::new();
851 let mut past_double_dash = false;
852 for arg in args {
853 match arg {
854 Arg::Positional(expr) => words.push(expr_to_placeholder(expr)),
855 Arg::ShortFlag(name) => words.push(Value::String(format!("-{name}"))),
856 Arg::LongFlag(name) => {
857 if lift_global_flags && !past_double_dash && is_global_output_flag(name) {
858 tool_args.flags.insert(name.clone());
859 } else {
860 words.push(Value::String(format!("--{name}")));
861 }
862 }
863 Arg::Named { key, value } => {
864 if lift_global_flags && !past_double_dash && is_global_output_flag(key) {
865 if global_flag_value_is_truthy(&expr_to_placeholder(value)) {
871 tool_args.flags.insert(key.clone());
872 }
873 } else {
874 words.push(Value::String(format!("--{key}=<value>")));
875 }
876 }
877 Arg::WordAssign { key, .. } => {
878 words.push(Value::String(format!("{key}=<value>")));
879 }
880 Arg::DoubleDash => {
881 past_double_dash = true;
882 words.push(Value::String("--".to_string()));
883 }
884 }
885 }
886 tool_args.words = Some(words);
887 return tool_args;
888 }
889
890 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
894 let mut consumed: HashSet<usize> = HashSet::new();
895 let mut past_double_dash = false;
896
897 for i in 0..args.len() {
898 match &args[i] {
899 Arg::DoubleDash => past_double_dash = true,
900 Arg::Positional(expr) => {
901 if !consumed.contains(&i) {
902 tool_args.positional.push(expr_to_placeholder(expr));
903 }
904 }
905 Arg::Named { key, value } => {
906 let v = expr_to_placeholder(value);
907 if past_double_dash {
909 tool_args
910 .positional
911 .push(Value::String(format!("--{key}={}", crate::interpreter::value_to_string(&v))));
912 continue;
913 }
914 if !past_double_dash && is_global_output_flag(key) {
921 if global_flag_value_is_truthy(&v) {
922 tool_args.flags.insert(key.clone());
923 }
924 continue;
925 }
926 match param_lookup.get(key.as_str()) {
927 Some(&(canonical, _, _, true)) => {
929 let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
930 }
931 Some(&(canonical, ..)) => {
932 tool_args.named.insert(canonical.to_string(), v);
933 }
934 None => {
935 tool_args.named.insert(key.clone(), v);
936 }
937 }
938 }
939 Arg::WordAssign { key, value } => {
940 tool_args.named.insert(key.clone(), expr_to_placeholder(value));
944 }
945 Arg::ShortFlag(name) => {
946 if past_double_dash {
947 tool_args.positional.push(Value::String(format!("-{name}")));
948 } else {
949 bind_short_flag_for_validation(
950 name,
951 ¶m_lookup,
952 args,
953 i,
954 &mut consumed,
955 &mut tool_args,
956 );
957 }
958 }
959 Arg::LongFlag(name) => {
960 if past_double_dash {
961 tool_args.positional.push(Value::String(format!("--{name}")));
962 } else {
963 match param_lookup.get(name.as_str()) {
964 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
965 bind_value_or_flag(
966 &mut tool_args, name, canonical, consumes, repeatable, args, i,
967 &mut consumed,
968 );
969 }
970 Some(&(canonical, ..)) => {
971 tool_args.flags.insert(canonical.to_string());
972 }
973 None => {
974 tool_args.flags.insert(name.clone());
975 }
976 }
977 }
978 }
979 }
980 }
981
982 tool_args
983}
984
985fn bind_short_flag_for_validation(
991 name: &str,
992 param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
993 args: &[Arg],
994 i: usize,
995 consumed: &mut HashSet<usize>,
996 tool_args: &mut ToolArgs,
997) {
998 if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
1000 if is_bool_type(typ) {
1001 tool_args.flags.insert(canonical.to_string());
1002 } else {
1003 bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
1004 }
1005 return;
1006 }
1007 if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
1009 .get(&name[..1])
1010 .filter(|(_, typ, ..)| !is_bool_type(typ))
1011 {
1012 let glued = name[1..].to_string();
1013 if glued.is_empty() {
1014 bind_value_or_flag(
1015 tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
1016 );
1017 } else {
1018 let _ =
1019 bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
1020 }
1021 return;
1022 }
1023 let bytes = name.as_bytes();
1026 let mut p = 0;
1027 while p < bytes.len() {
1028 let key = &name[p..p + 1];
1029 match param_lookup.get(key) {
1030 Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
1031 let glued = name[p + 1..].to_string();
1032 if glued.is_empty() {
1033 bind_value_or_flag(
1034 tool_args, key, canonical, consumes, repeatable, args, i, consumed,
1035 );
1036 } else {
1037 let _ = bind_glued_short_value(
1038 tool_args, key, canonical, consumes, repeatable, glued,
1039 );
1040 }
1041 return;
1042 }
1043 _ => {
1044 tool_args.flags.insert(key.to_string());
1045 p += 1;
1046 }
1047 }
1048 }
1049}
1050
1051#[allow(clippy::too_many_arguments)] fn bind_value_or_flag(
1060 tool_args: &mut ToolArgs,
1061 flag_name: &str,
1062 canonical: &str,
1063 consumes: usize,
1064 repeatable: bool,
1065 args: &[Arg],
1066 i: usize,
1067 consumed: &mut HashSet<usize>,
1068) {
1069 let want = consumes.max(1);
1070 let allow_word_assign = consumes <= 1;
1071 let mut collected: Vec<Value> = Vec::with_capacity(want);
1072 for _ in 0..want {
1073 let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
1074 let idx = i + 1 + off;
1075 if consumed.contains(&idx) {
1076 return None;
1077 }
1078 match a {
1079 Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
1080 Arg::WordAssign { key, value } if allow_word_assign => {
1081 let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
1082 Some((idx, Value::String(format!("{key}={s}"))))
1083 }
1084 _ => None,
1085 }
1086 });
1087 match found {
1088 Some((idx, v)) => {
1089 consumed.insert(idx);
1090 collected.push(v);
1091 }
1092 None => break,
1093 }
1094 }
1095
1096 if collected.is_empty() {
1097 tool_args.flags.insert(canonical.to_string());
1098 return;
1099 }
1100 if consumes <= 1 {
1101 if let Some(v) = collected.into_iter().next() {
1102 if repeatable {
1103 let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
1114 } else {
1115 tool_args.named.insert(canonical.to_string(), v);
1116 }
1117 }
1118 return;
1119 }
1120 let occ: Vec<serde_json::Value> = collected
1126 .iter()
1127 .map(crate::interpreter::value_to_json)
1128 .collect();
1129 let entry = tool_args
1130 .named
1131 .entry(canonical.to_string())
1132 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
1133 if let Value::Json(serde_json::Value::Array(outer)) = entry {
1134 outer.push(serde_json::Value::Array(occ));
1135 }
1136}
1137
1138fn expr_to_placeholder(expr: &Expr) -> Value {
1143 match expr {
1144 Expr::Literal(val) => val.clone(),
1145 Expr::Interpolated(parts) if parts.len() == 1 => {
1146 if let StringPart::Literal(s) = &parts[0] {
1147 Value::String(s.clone())
1148 } else {
1149 Value::String("<dynamic>".to_string())
1150 }
1151 }
1152 _ => Value::String("<dynamic>".to_string()),
1154 }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::*;
1160 use crate::tools::{register_builtins, ToolRegistry};
1161
1162 fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
1163 let mut registry = ToolRegistry::new();
1164 register_builtins(&mut registry);
1165 let user_tools = HashMap::new();
1166 (registry, user_tools)
1167 }
1168
1169 #[test]
1175 fn validation_binds_json_value_the_way_execution_does() {
1176 let schema = ToolSchema::new("probe", "probe");
1177 let named = |v: Value| {
1178 vec![Arg::Named { key: "json".to_string(), value: Expr::Literal(v) }]
1179 };
1180
1181 for on in [Value::Int(1), Value::String("yes".into()), Value::Bool(true)] {
1182 let args = build_tool_args_for_validation(&named(on.clone()), Some(&schema));
1183 assert!(args.flags.contains("json"), "{on:?} should bind --json on");
1184 assert!(!args.named.contains_key("json"), "{on:?} must not reach named");
1185 }
1186
1187 for off in [Value::Int(0), Value::String("0".into()), Value::Bool(false)] {
1188 let args = build_tool_args_for_validation(&named(off.clone()), Some(&schema));
1189 assert!(!args.flags.contains("json"), "{off:?} should bind --json off");
1190 assert!(!args.named.contains_key("json"), "{off:?} must not reach named");
1191 }
1192 }
1193
1194 #[test]
1199 fn validation_binds_dynamic_json_value_as_on() {
1200 let schema = ToolSchema::new("probe", "probe");
1201 let args = build_tool_args_for_validation(
1202 &[Arg::Named {
1203 key: "json".to_string(),
1204 value: Expr::VarRef(VarPath::simple("MODE")),
1205 }],
1206 Some(&schema),
1207 );
1208 assert!(args.flags.contains("json"));
1209 }
1210
1211 #[test]
1216 fn validation_keeps_json_after_double_dash_out_of_flags() {
1217 let schema = ToolSchema::new("probe", "probe");
1218 let args = build_tool_args_for_validation(
1219 &[
1220 Arg::DoubleDash,
1221 Arg::Named {
1222 key: "json".to_string(),
1223 value: Expr::Literal(Value::Bool(true)),
1224 },
1225 ],
1226 Some(&schema),
1227 );
1228 assert!(!args.flags.contains("json"), "flags: {:?}", args.flags);
1229 }
1230
1231 #[test]
1232 fn validates_undefined_command() {
1233 let (registry, user_tools) = make_validator();
1234 let validator = Validator::new(®istry, &user_tools, &[]);
1235
1236 let program = Program {
1237 statements: vec![Stmt::Command(Command {
1238 name: "nonexistent_command".to_string(),
1239 args: vec![],
1240 redirects: vec![],
1241 })],
1242 };
1243
1244 let issues = validator.validate(&program);
1245 assert!(!issues.is_empty());
1246 assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1247 }
1248
1249 #[test]
1253 fn test_command_is_a_known_builtin() {
1254 let (registry, user_tools) = make_validator();
1255 let validator = Validator::new(®istry, &user_tools, &[]);
1256
1257 let program = Program {
1258 statements: vec![Stmt::Command(Command {
1259 name: "test".to_string(),
1260 args: vec![
1261 Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
1262 Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
1263 ],
1264 redirects: vec![],
1265 })],
1266 };
1267
1268 let issues = validator.validate(&program);
1269 assert!(
1270 !issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
1271 "`test` is a builtin — no undefined-command warning: {issues:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn validates_known_command() {
1277 let (registry, user_tools) = make_validator();
1278 let validator = Validator::new(®istry, &user_tools, &[]);
1279
1280 let program = Program {
1281 statements: vec![Stmt::Command(Command {
1282 name: "echo".to_string(),
1283 args: vec![Arg::Positional(Expr::Literal(Value::String(
1284 "hello".to_string(),
1285 )))],
1286 redirects: vec![],
1287 })],
1288 };
1289
1290 let issues = validator.validate(&program);
1291 assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1293 }
1294
1295 #[test]
1321 fn catalog_hit_and_fallback_produce_identical_schema_driven_issues() {
1322 let (registry, user_tools) = make_validator();
1323 let catalog = registry.schemas();
1324 assert!(
1325 catalog.binary_search_by(|s| s.name.as_str().cmp("jq")).is_ok(),
1326 "fixture assumption: `jq` must be in the catalog for this to be a real hit"
1327 );
1328
1329 let program = Program {
1330 statements: vec![Stmt::Command(Command {
1331 name: "jq".to_string(),
1332 args: vec![],
1333 redirects: vec![],
1334 })],
1335 };
1336
1337 let fallback_issues =
1338 Validator::new(®istry, &user_tools, &[]).validate(&program);
1339 let catalog_issues =
1340 Validator::new(®istry, &user_tools, &catalog).validate(&program);
1341
1342 assert!(
1345 fallback_issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1346 "test input should trip MissingRequiredArg (`filter`) via the fallback path; \
1347 got {fallback_issues:?}"
1348 );
1349
1350 fn render(issues: &[ValidationIssue]) -> Vec<(Severity, IssueCode, &str, Option<&str>)> {
1351 issues
1352 .iter()
1353 .map(|i| (i.severity, i.code, i.message.as_str(), i.suggestion.as_deref()))
1354 .collect()
1355 }
1356 assert_eq!(
1357 render(&fallback_issues),
1358 render(&catalog_issues),
1359 "catalog-hit and tool.schema()-fallback validation must agree exactly \
1360 (same codes, same messages, same order); fallback={fallback_issues:?} \
1361 catalog={catalog_issues:?}"
1362 );
1363 }
1364
1365 #[test]
1366 fn glued_value_flags_dont_false_error_at_validation() {
1367 let (registry, user_tools) = make_validator();
1373 let validator = Validator::new(®istry, &user_tools, &[]);
1374
1375 let program = Program {
1376 statements: vec![Stmt::Command(Command {
1377 name: "sed".to_string(),
1378 args: vec![
1379 Arg::ShortFlag("e1d".to_string()),
1380 Arg::ShortFlag("e2d".to_string()),
1381 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1382 ],
1383 redirects: vec![],
1384 })],
1385 };
1386
1387 let issues = validator.validate(&program);
1388 assert!(
1389 !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
1390 "glued -e flags false-errored at validation: {:?}",
1391 issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1392 );
1393 }
1394
1395 #[test]
1396 fn validates_break_outside_loop() {
1397 let (registry, user_tools) = make_validator();
1398 let validator = Validator::new(®istry, &user_tools, &[]);
1399
1400 let program = Program {
1401 statements: vec![Stmt::Break(None)],
1402 };
1403
1404 let issues = validator.validate(&program);
1405 assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1406 }
1407
1408 #[test]
1409 fn validates_break_inside_loop() {
1410 let (registry, user_tools) = make_validator();
1411 let validator = Validator::new(®istry, &user_tools, &[]);
1412
1413 let program = Program {
1414 statements: vec![Stmt::For(ForLoop {
1415 variable: "i".to_string(),
1416 items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
1417 body: vec![Stmt::Break(None)],
1418 })],
1419 };
1420
1421 let issues = validator.validate(&program);
1422 assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1424 }
1425
1426 #[test]
1427 fn validates_undefined_variable() {
1428 let (registry, user_tools) = make_validator();
1429 let validator = Validator::new(®istry, &user_tools, &[]);
1430
1431 let program = Program {
1432 statements: vec![Stmt::Command(Command {
1433 name: "echo".to_string(),
1434 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
1435 "UNDEFINED_VAR",
1436 )))],
1437 redirects: vec![],
1438 })],
1439 };
1440
1441 let issues = validator.validate(&program);
1442 assert!(issues
1443 .iter()
1444 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1445 }
1446
1447 #[test]
1448 fn validates_defined_variable() {
1449 let (registry, user_tools) = make_validator();
1450 let validator = Validator::new(®istry, &user_tools, &[]);
1451
1452 let program = Program {
1453 statements: vec![
1454 Stmt::Assignment(Assignment {
1456 path: VarPath::simple("MY_VAR"),
1457 value: Expr::Literal(Value::String("value".to_string())),
1458 local: false,
1459 }),
1460 Stmt::Command(Command {
1462 name: "echo".to_string(),
1463 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
1464 redirects: vec![],
1465 }),
1466 ],
1467 };
1468
1469 let issues = validator.validate(&program);
1470 assert!(!issues
1472 .iter()
1473 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1474 && i.message.contains("MY_VAR")));
1475 }
1476
1477 #[test]
1478 fn skips_underscore_prefixed_vars() {
1479 let (registry, user_tools) = make_validator();
1480 let validator = Validator::new(®istry, &user_tools, &[]);
1481
1482 let program = Program {
1483 statements: vec![Stmt::Command(Command {
1484 name: "echo".to_string(),
1485 args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1486 redirects: vec![],
1487 })],
1488 };
1489
1490 let issues = validator.validate(&program);
1491 assert!(!issues
1493 .iter()
1494 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1495 }
1496
1497 #[test]
1498 fn builtin_vars_are_defined() {
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![
1506 Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1507 Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1508 Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1509 ],
1510 redirects: vec![],
1511 })],
1512 };
1513
1514 let issues = validator.validate(&program);
1515 assert!(!issues
1517 .iter()
1518 .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1519 }
1520
1521 #[test]
1522 fn validates_scatter_without_gather() {
1523 let (registry, user_tools) = make_validator();
1524 let validator = Validator::new(®istry, &user_tools, &[]);
1525
1526 let program = Program {
1527 statements: vec![Stmt::Pipeline(Pipeline {
1528 stages: vec![
1529 Command { name: "seq".to_string(), args: vec![
1530 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1531 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1532 ], redirects: vec![] },
1533 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1534 Command { name: "echo".to_string(), args: vec![
1535 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1536 ], redirects: vec![] },
1537 ]
1538 .into_iter()
1539 .map(PipelineStage::Command)
1540 .collect(),
1541 background: false,
1542 })],
1543 };
1544
1545 let issues = validator.validate(&program);
1546 assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1547 "should flag scatter without gather: {:?}", issues);
1548 }
1549
1550 #[test]
1551 fn allows_scatter_with_gather() {
1552 let (registry, user_tools) = make_validator();
1553 let validator = Validator::new(®istry, &user_tools, &[]);
1554
1555 let program = Program {
1556 statements: vec![Stmt::Pipeline(Pipeline {
1557 stages: vec![
1558 Command { name: "seq".to_string(), args: vec![
1559 Arg::Positional(Expr::Literal(Value::String("1".into()))),
1560 Arg::Positional(Expr::Literal(Value::String("3".into()))),
1561 ], redirects: vec![] },
1562 Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1563 Command { name: "echo".to_string(), args: vec![
1564 Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1565 ], redirects: vec![] },
1566 Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1567 ]
1568 .into_iter()
1569 .map(PipelineStage::Command)
1570 .collect(),
1571 background: false,
1572 })],
1573 };
1574
1575 let issues = validator.validate(&program);
1576 assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1577 "scatter with gather should pass: {:?}", issues);
1578 }
1579
1580 fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1581 let mut user_tools = HashMap::new();
1582 user_tools.insert(
1583 "mytool".to_string(),
1584 ToolDef {
1585 name: "mytool".to_string(),
1586 params: vec![crate::ast::ParamDef {
1587 name: "input".to_string(),
1588 param_type: None,
1589 default: None,
1590 }],
1591 body: vec![],
1592 },
1593 );
1594 user_tools
1595 }
1596
1597 #[test]
1601 fn user_tool_wordassign_counts_as_positional() {
1602 let mut registry = ToolRegistry::new();
1603 register_builtins(&mut registry);
1604 let user_tools = make_user_tool_with_required_positional();
1605 let validator = Validator::new(®istry, &user_tools, &[]);
1606
1607 let program = Program {
1608 statements: vec![Stmt::Command(Command {
1609 name: "mytool".to_string(),
1610 args: vec![Arg::WordAssign {
1611 key: "foo".to_string(),
1612 value: Expr::Literal(Value::String("bar".to_string())),
1613 }],
1614 redirects: vec![],
1615 })],
1616 };
1617
1618 let issues = validator.validate(&program);
1619 assert!(
1620 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1621 "WordAssign should satisfy required positional; got {:?}",
1622 issues
1623 );
1624 }
1625
1626 #[test]
1629 fn user_tool_no_args_still_errors() {
1630 let mut registry = ToolRegistry::new();
1631 register_builtins(&mut registry);
1632 let user_tools = make_user_tool_with_required_positional();
1633 let validator = Validator::new(®istry, &user_tools, &[]);
1634
1635 let program = Program {
1636 statements: vec![Stmt::Command(Command {
1637 name: "mytool".to_string(),
1638 args: vec![],
1639 redirects: vec![],
1640 })],
1641 };
1642
1643 let issues = validator.validate(&program);
1644 assert!(
1645 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1646 "missing positional should still error; got {:?}",
1647 issues
1648 );
1649 }
1650}