Skip to main content

kaish_kernel/validator/
walker.rs

1//! AST walker for pre-execution validation.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::ast::{
6    Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
7    Program, SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop,
8    Value,
9};
10use crate::kernel::{bind_glued_short_value, push_repeatable_value};
11use crate::scheduler::{is_bool_type, schema_param_lookup};
12use crate::validator::issue::Span;
13use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
14use kaish_types::CommandKind;
15
16use super::issue::{IssueCode, ValidationIssue};
17use super::scope_tracker::ScopeTracker;
18
19/// AST validator that checks for issues before execution.
20pub struct Validator<'a> {
21    /// Reference to the tool registry.
22    registry: &'a ToolRegistry,
23    /// User-defined tools.
24    user_tools: &'a HashMap<String, ToolDef>,
25    /// Variable scope tracker.
26    scope: ScopeTracker,
27    /// Current loop nesting depth.
28    loop_depth: usize,
29    /// Current function nesting depth.
30    function_depth: usize,
31    /// Collected validation issues.
32    issues: Vec<ValidationIssue>,
33}
34
35impl<'a> Validator<'a> {
36    /// Create a new validator.
37    pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
38        Self {
39            registry,
40            user_tools,
41            scope: ScopeTracker::new(),
42            loop_depth: 0,
43            function_depth: 0,
44            issues: Vec::new(),
45        }
46    }
47
48    /// Validate a program and return all issues found.
49    pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
50        for stmt in &program.statements {
51            self.validate_stmt(stmt);
52        }
53        self.issues
54    }
55
56    /// Validate a single statement.
57    fn validate_stmt(&mut self, stmt: &Stmt) {
58        match stmt {
59            Stmt::Assignment(assign) => self.validate_assignment(assign),
60            Stmt::Command(cmd) => self.validate_command(cmd),
61            Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
62            Stmt::If(if_stmt) => self.validate_if(if_stmt),
63            Stmt::For(for_loop) => self.validate_for(for_loop),
64            Stmt::While(while_loop) => self.validate_while(while_loop),
65            Stmt::Case(case_stmt) => self.validate_case(case_stmt),
66            Stmt::Break(levels) => self.validate_break(*levels),
67            Stmt::Continue(levels) => self.validate_continue(*levels),
68            Stmt::Return(expr) => self.validate_return(expr.as_deref()),
69            Stmt::Exit(expr) => {
70                if let Some(e) = expr {
71                    self.validate_expr(e);
72                }
73            }
74            Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
75            Stmt::Test(test_expr) => self.validate_test(test_expr),
76            Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
77                self.validate_stmt(left);
78                self.validate_stmt(right);
79            }
80            Stmt::EnvScoped { assignments, body } => {
81                // Validate each prefix assignment (values + bind the name so the
82                // body's references resolve), then the command it scopes.
83                for assign in assignments {
84                    self.validate_assignment(assign);
85                }
86                self.validate_stmt(body);
87            }
88            Stmt::Empty => {}
89        }
90    }
91
92    /// Validate an assignment statement.
93    ///
94    /// A plain `NAME=value` is checked for a dotted target (`user.email=x`) —
95    /// kaish is brackets-only for collection access, and the `Ident` token
96    /// admits `.` for other uses (filenames), so a dotted assignment target
97    /// is caught here rather than by tightening the lexer regex. A
98    /// subscripted lvalue (`x[k]=v`) additionally requires the root to
99    /// already be bound — a path-set never autovivifies the root (see
100    /// `docs/arrays-and-hashes.md`, "Assignment lvalues").
101    fn validate_assignment(&mut self, assign: &Assignment) {
102        // Validate the value expression
103        self.validate_expr(&assign.value);
104
105        let name = assign.name();
106        if assign.path.segments.len() == 1 {
107            if let Some(dot) = name.find('.') {
108                let (root, rest) = (&name[..dot], &name[dot + 1..]);
109                self.issues.push(
110                    ValidationIssue::error(
111                        IssueCode::DottedAssignmentTarget,
112                        format!(
113                            "'{name}' is not a valid assignment target — kaish uses bracket \
114                             access, not dots"
115                        ),
116                    )
117                    .with_suggestion(format!("use `{root}[{rest}]=value`")),
118                );
119            }
120            // Bind the variable name in scope
121            self.scope.bind(name);
122        } else if !self.scope.is_bound(name) {
123            self.issues.push(
124                ValidationIssue::error(
125                    IssueCode::LvalueUndefinedRoot,
126                    format!(
127                        "'{name}' is not defined — a subscripted assignment never creates the \
128                         root variable"
129                    ),
130                )
131                .with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
132            );
133            // Bind it anyway so a later reference to the same (still-invalid)
134            // root doesn't ALSO trigger a separate undefined-variable warning.
135            self.scope.bind(name);
136        }
137    }
138
139    /// Validate a command invocation.
140    fn validate_command(&mut self, cmd: &Command) {
141        // Skip source/. commands - they're dynamic
142        if cmd.name == "source" || cmd.name == "." {
143            return;
144        }
145
146        // Skip dynamic command names (variable expansions)
147        if !is_static_command_name(&cmd.name) {
148            return;
149        }
150
151        // Check if command exists
152        let is_builtin = self.registry.contains(&cmd.name);
153        let is_user_tool = self.user_tools.contains_key(&cmd.name);
154        let is_special = is_special_command(&cmd.name);
155
156        if !is_builtin && !is_user_tool && !is_special {
157            // Warning only - command might be a script in PATH or external tool.
158            // (`test` is now a first-class builtin — VFS-aware, validated — so it
159            // takes the `is_builtin` path above and never lands here.)
160            self.issues.push(ValidationIssue::warning(
161                IssueCode::UndefinedCommand,
162                format!("command '{}' not found in builtin registry", cmd.name),
163            ).with_suggestion("this may be a script in PATH or external command"));
164        }
165
166        // Validate arguments expressions
167        for arg in &cmd.args {
168            self.validate_arg(arg);
169        }
170
171        // If we have a schema, validate args against it. Pass the schema so the
172        // arg-builder binds glued/value short-flags the same way execute does —
173        // otherwise a tool whose validate() reads positionals semantically (sed,
174        // awk) misreads them (docs/issues.md: schema-blind validation builder).
175        if let Some(tool) = self.registry.get(&cmd.name) {
176            let schema = tool.schema();
177            let tool_args = build_tool_args_for_validation(&cmd.args, Some(&schema));
178            let tool_issues = tool.validate(&tool_args);
179            self.issues.extend(tool_issues);
180        } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
181            // Validate against user-defined tool parameters
182            self.validate_user_tool_args(user_tool, &cmd.args);
183        }
184
185        // Validate redirects
186        for redirect in &cmd.redirects {
187            self.validate_expr(&redirect.target);
188        }
189    }
190
191    /// Validate a command argument.
192    fn validate_arg(&mut self, arg: &Arg) {
193        match arg {
194            Arg::Positional(expr) => self.validate_expr(expr),
195            Arg::Named { value, .. } => self.validate_expr(value),
196            Arg::WordAssign { value, .. } => self.validate_expr(value),
197            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
198        }
199    }
200
201    /// Validate a pipeline.
202    fn validate_pipeline(&mut self, pipe: &Pipeline) {
203        // Check for scatter without gather
204        let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
205        let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
206        if has_scatter && !has_gather {
207            self.issues.push(
208                ValidationIssue::error(
209                    IssueCode::ScatterWithoutGather,
210                    "scatter without gather — parallel results would be lost",
211                ).with_suggestion("add gather: ... | scatter | cmd | gather")
212            );
213        }
214
215        for cmd in &pipe.commands {
216            self.validate_command(cmd);
217        }
218    }
219
220    /// Validate an if statement.
221    fn validate_if(&mut self, if_stmt: &IfStmt) {
222        self.validate_expr(&if_stmt.condition);
223
224        self.scope.push_frame();
225        for stmt in &if_stmt.then_branch {
226            self.validate_stmt(stmt);
227        }
228        self.scope.pop_frame();
229
230        if let Some(else_branch) = &if_stmt.else_branch {
231            self.scope.push_frame();
232            for stmt in else_branch {
233                self.validate_stmt(stmt);
234            }
235            self.scope.pop_frame();
236        }
237    }
238
239    /// Validate a for loop.
240    fn validate_for(&mut self, for_loop: &ForLoop) {
241        // Validate item expressions and check for bare scalar variables
242        for item in &for_loop.items {
243            self.validate_expr(item);
244
245            // Detect `for i in $VAR` pattern - always a mistake in kaish
246            // since we don't do implicit word splitting
247            if self.is_bare_scalar_var(item) {
248                self.issues.push(
249                    ValidationIssue::error(
250                        IssueCode::ForLoopScalarVar,
251                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
252                    )
253                    .with_suggestion(concat!(
254                        "wrap it in $(...) — for a collection use keys/values:\n",
255                        "    for x in $(values $coll)      # list elements / record values\n",
256                        "    for k in $(keys $coll)        # list indices / record keys\n",
257                        "    for i in $(split \"$VAR\")       # split a string on whitespace\n",
258                        "    for i in $(split \"$VAR\" \":\")   # split a string on a delimiter\n",
259                        "    for i in $(seq 1 10)          # iterate numbers\n",
260                        "    for i in $(glob \"*.rs\")        # iterate files",
261                    )),
262                );
263            }
264        }
265
266        self.loop_depth += 1;
267        self.scope.push_frame();
268
269        // Bind loop variable
270        self.scope.bind(&for_loop.variable);
271
272        for stmt in &for_loop.body {
273            self.validate_stmt(stmt);
274        }
275
276        self.scope.pop_frame();
277        self.loop_depth -= 1;
278    }
279
280    /// Extract glob pattern from unquoted literal expressions.
281    ///
282    /// Check if an expression is a bare scalar variable reference.
283    ///
284    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
285    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
286        match expr {
287            // Direct variable reference like $VAR or ${VAR}
288            Expr::VarRef(_) => true,
289            // Variable with default like ${VAR:-default} - also problematic
290            Expr::VarWithDefault { .. } => true,
291            // NOT a problem: command substitution like $(cmd) - returns structured data
292            Expr::CommandSubst(_) => false,
293            // NOT a problem: literals are fine
294            Expr::Literal(_) => false,
295            // NOT a problem: interpolated strings are a single value
296            Expr::Interpolated(_) => false,
297            // Everything else: not a bare scalar var
298            _ => false,
299        }
300    }
301
302    /// Validate a while loop.
303    fn validate_while(&mut self, while_loop: &WhileLoop) {
304        self.validate_expr(&while_loop.condition);
305
306        self.loop_depth += 1;
307        self.scope.push_frame();
308
309        for stmt in &while_loop.body {
310            self.validate_stmt(stmt);
311        }
312
313        self.scope.pop_frame();
314        self.loop_depth -= 1;
315    }
316
317    /// Validate a case statement.
318    fn validate_case(&mut self, case_stmt: &CaseStmt) {
319        self.validate_expr(&case_stmt.expr);
320
321        for branch in &case_stmt.branches {
322            self.validate_case_branch(branch);
323        }
324    }
325
326    /// Validate a case branch.
327    fn validate_case_branch(&mut self, branch: &CaseBranch) {
328        self.scope.push_frame();
329        for stmt in &branch.body {
330            self.validate_stmt(stmt);
331        }
332        self.scope.pop_frame();
333    }
334
335    /// Validate a break statement.
336    fn validate_break(&mut self, levels: Option<usize>) {
337        if self.loop_depth == 0 {
338            self.issues.push(ValidationIssue::error(
339                IssueCode::BreakOutsideLoop,
340                "break used outside of a loop",
341            ));
342        } else if let Some(n) = levels
343            && n > self.loop_depth {
344                self.issues.push(ValidationIssue::warning(
345                    IssueCode::BreakOutsideLoop,
346                    format!(
347                        "break {} exceeds loop nesting depth {}",
348                        n, self.loop_depth
349                    ),
350                ));
351            }
352    }
353
354    /// Validate a continue statement.
355    fn validate_continue(&mut self, levels: Option<usize>) {
356        if self.loop_depth == 0 {
357            self.issues.push(ValidationIssue::error(
358                IssueCode::BreakOutsideLoop,
359                "continue used outside of a loop",
360            ));
361        } else if let Some(n) = levels
362            && n > self.loop_depth {
363                self.issues.push(ValidationIssue::warning(
364                    IssueCode::BreakOutsideLoop,
365                    format!(
366                        "continue {} exceeds loop nesting depth {}",
367                        n, self.loop_depth
368                    ),
369                ));
370            }
371    }
372
373    /// Validate a return statement.
374    fn validate_return(&mut self, expr: Option<&Expr>) {
375        if let Some(e) = expr {
376            self.validate_expr(e);
377        }
378
379        if self.function_depth == 0 {
380            self.issues.push(ValidationIssue::error(
381                IssueCode::ReturnOutsideFunction,
382                "return used outside of a function",
383            ));
384        }
385    }
386
387    /// Validate a tool definition.
388    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
389        self.function_depth += 1;
390        self.scope.push_frame();
391
392        // Bind parameters
393        for param in &tool_def.params {
394            self.scope.bind(&param.name);
395            // Validate default expressions
396            if let Some(default) = &param.default {
397                self.validate_expr(default);
398            }
399        }
400
401        // Validate body
402        for stmt in &tool_def.body {
403            self.validate_stmt(stmt);
404        }
405
406        self.scope.pop_frame();
407        self.function_depth -= 1;
408    }
409
410    /// Validate a test expression.
411    fn validate_test(&mut self, test: &TestExpr) {
412        match test {
413            TestExpr::FileTest { path, .. } => self.validate_expr(path),
414            TestExpr::StringTest { value, .. } => self.validate_expr(value),
415            TestExpr::Comparison { left, right, .. } => {
416                self.validate_expr(left);
417                self.validate_expr(right);
418            }
419            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
420                self.validate_test(left);
421                self.validate_test(right);
422            }
423            TestExpr::Not { expr } => self.validate_test(expr),
424            TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
425                self.validate_expr(left);
426                self.validate_expr(right);
427            }
428        }
429    }
430
431    /// Validate an expression.
432    fn validate_expr(&mut self, expr: &Expr) {
433        match expr {
434            Expr::Literal(_) => {}
435            Expr::VarRef(path) => self.validate_var_ref(path),
436            Expr::Interpolated(parts) => {
437                for part in parts {
438                    self.validate_string_part(part);
439                }
440            }
441            Expr::HereDocBody { parts, .. } => {
442                for sp in parts {
443                    self.validate_spanned_string_part(sp);
444                }
445            }
446            Expr::BinaryOp { left, right, .. } => {
447                self.validate_expr(left);
448                self.validate_expr(right);
449            }
450            Expr::CommandSubst(stmts) => {
451                for stmt in stmts {
452                    self.validate_stmt(stmt);
453                }
454            }
455            Expr::Test(test) => self.validate_test(test),
456            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
457            Expr::VarLength(path) => {
458                if let Some(VarSegment::Field(root)) = path.segments.first() {
459                    self.check_var_defined(root);
460                }
461            }
462            Expr::VarWithDefault { .. } => {
463                // Don't warn — the default handles the undefined/absent case.
464            }
465            Expr::Arithmetic(_) => {
466                // Arithmetic parsing is done at runtime
467            }
468            Expr::Command(cmd) => self.validate_command(cmd),
469            Expr::LastExitCode | Expr::CurrentPid => {}
470            Expr::GlobPattern(_) => {}
471            Expr::ListLiteral(elems) => {
472                for elem in elems {
473                    match elem {
474                        ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
475                    }
476                }
477            }
478            Expr::RecordLiteral(entries) => {
479                for entry in entries {
480                    self.validate_expr(&entry.value);
481                }
482            }
483        }
484    }
485
486    /// Validate a variable reference.
487    fn validate_var_ref(&mut self, path: &VarPath) {
488        if let Some(VarSegment::Field(name)) = path.segments.first() {
489            // `${?.field}` is removed — $? is the POSIX integer exit code.
490            // Use `kaish-last` to access the previous command's structured data.
491            if name == "?" && path.segments.len() > 1 {
492                self.issues.push(
493                    ValidationIssue::error(
494                        IssueCode::LastResultFieldAccess,
495                        "${?.field} is removed; $? is the POSIX exit code",
496                    )
497                    .with_suggestion(
498                        "use `kaish-last` to read the previous command's data or stdout",
499                    ),
500                );
501                return;
502            }
503            self.check_var_defined(name);
504        }
505    }
506
507    /// Validate a spanned heredoc-body part, attaching the part's span to any
508    /// new issues raised during the inner walk. Issues already carrying a span
509    /// are left alone (e.g., from a nested validator that already knew better).
510    fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
511        let issues_before = self.issues.len();
512        self.validate_string_part(&sp.part);
513        let span = Span::new(sp.offset, sp.offset + sp.len);
514        for issue in &mut self.issues[issues_before..] {
515            if issue.span.is_none() {
516                issue.span = Some(span);
517            }
518        }
519    }
520
521    /// Validate a string interpolation part.
522    fn validate_string_part(&mut self, part: &StringPart) {
523        match part {
524            StringPart::Literal(_) => {}
525            StringPart::Var(path) => self.validate_var_ref(path),
526            StringPart::VarWithDefault { default, .. } => {
527                // Validate nested parts in the default value
528                for p in default {
529                    self.validate_string_part(p);
530                }
531            }
532            StringPart::VarLength(path) => {
533                if let Some(VarSegment::Field(root)) = path.segments.first() {
534                    self.check_var_defined(root);
535                }
536            }
537            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
538            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
539            StringPart::CommandSubst(stmts) => {
540                for stmt in stmts {
541                    self.validate_stmt(stmt);
542                }
543            }
544            StringPart::LastExitCode | StringPart::CurrentPid => {}
545        }
546    }
547
548    /// Check if a variable is defined and warn if not.
549    fn check_var_defined(&mut self, name: &str) {
550        // Skip underscore-prefixed vars (external/unchecked convention)
551        if ScopeTracker::should_skip_undefined_check(name) {
552            return;
553        }
554
555        if !self.scope.is_bound(name) {
556            self.issues.push(ValidationIssue::warning(
557                IssueCode::PossiblyUndefinedVariable,
558                format!("variable '{}' may be undefined", name),
559            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
560        }
561    }
562
563    /// Validate arguments against a user-defined tool's parameters.
564    ///
565    /// Counts bareword `key=value` (`Arg::WordAssign`) as positional: user
566    /// tools aren't on `WORD_ASSIGN_BUILTINS`, so at runtime the kernel
567    /// stringifies WordAssign into a positional `"key=value"` (matches bash).
568    /// Skipping it here would falsely error `mytool foo=bar` when mytool has
569    /// one required positional.
570    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
571        let positional_count = args
572            .iter()
573            .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
574            .count();
575
576        let required_count = tool_def
577            .params
578            .iter()
579            .filter(|p| p.default.is_none())
580            .count();
581
582        if positional_count < required_count {
583            self.issues.push(ValidationIssue::error(
584                IssueCode::MissingRequiredArg,
585                format!(
586                    "'{}' requires {} arguments, got {}",
587                    tool_def.name, required_count, positional_count
588                ),
589            ));
590        }
591    }
592}
593
594/// Check if a command name is static (not a variable expansion).
595///
596/// The parser only ever produces a literal `Command.name`, so for the AST-walk
597/// path this is always true; the `${…}` / `$(…)` guards matter for the
598/// string-accepting `Kernel::classify_command` API, where a dynamic name
599/// classifies as [`CommandKind::Dynamic`] rather than a misleading `External`.
600pub(crate) fn is_static_command_name(name: &str) -> bool {
601    !name.starts_with('$') && !name.contains("$(") && !name.contains("${")
602}
603
604/// Interpreter special-forms — the command names `execute_command_depth`
605/// short-circuits before any alias/registry/`PATH` lookup.
606///
607/// **Single source of truth, compile-enforced.** `from_name` is the only place a
608/// name becomes "special", and the executor matches this enum *exhaustively*, so
609/// adding a form is a compile error until both the name mapping here and the
610/// execution behavior in `execute_command_depth` are updated — the two cannot
611/// silently diverge, and there's no `unreachable!` to panic at runtime.
612/// `classify_command` reports every special form as `CommandKind::Special` via
613/// [`is_runtime_special_form`].
614///
615/// This set is deliberately **narrower** than `is_special_command` below: that
616/// set is a validator warning heuristic (it suppresses "command not found" for
617/// names like `readonly`/`:` that the validator chooses not to flag), whereas
618/// this reflects what the interpreter *actually* short-circuits. Keeping them
619/// separate avoids `classify_command` mislabelling a name as internal when it
620/// would in fact escape to `PATH` (e.g. `readonly` resolves to an external
621/// command at runtime). See `Kernel::classify_command`.
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
623pub(crate) enum SpecialForm {
624    /// `true` — always succeeds (exit 0).
625    True,
626    /// `false` — always fails (exit 1).
627    False,
628    /// `source` / `.` — execute a script in the current shell.
629    Source,
630}
631
632impl SpecialForm {
633    /// The single mapping from a command name to a special-form, or `None` if the
634    /// name is resolved normally (alias/registry/`PATH`).
635    pub(crate) fn from_name(name: &str) -> Option<Self> {
636        match name {
637            "true" => Some(Self::True),
638            "false" => Some(Self::False),
639            "source" | "." => Some(Self::Source),
640            _ => None,
641        }
642    }
643}
644
645/// Whether `name` is an interpreter special-form (see [`SpecialForm`]).
646pub(crate) fn is_runtime_special_form(name: &str) -> bool {
647    SpecialForm::from_name(name).is_some()
648}
649
650/// Classify a command name the way the interpreter resolves it, given whether
651/// the registry and user-tool table contain it. Shared by the validator's
652/// triage and `Kernel::classify_command` so the two never diverge.
653pub(crate) fn classify_command_name(
654    name: &str,
655    is_builtin: bool,
656    is_user_tool: bool,
657) -> CommandKind {
658    if !is_static_command_name(name) {
659        return CommandKind::Dynamic;
660    }
661    if is_runtime_special_form(name) {
662        return CommandKind::Special;
663    }
664    // User functions are checked before builtins in `execute_command_depth`, so
665    // a user function shadows a builtin of the same name.
666    if is_user_tool {
667        return CommandKind::UserTool;
668    }
669    if is_builtin {
670        return CommandKind::Builtin;
671    }
672    CommandKind::External
673}
674
675/// Check if a command is a special built-in that we don't validate.
676fn is_special_command(name: &str) -> bool {
677    // `test`/`[`/`[[` are intentionally absent: `test` is a real builtin now
678    // (it validates via the registry like any other), and `[`/`[[` parse as
679    // `[[ … ]]` test expressions, never reaching here as a command name.
680    matches!(name, "true" | "false" | ":" | "readonly" | "local")
681}
682
683/// Build ToolArgs from AST Args for validation purposes.
684///
685/// This is a simplified version that doesn't evaluate expressions -
686/// it uses placeholder values since we only care about argument structure.
687pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
688    let mut tool_args = ToolArgs::new();
689    // Schema-aware param table: flag name → (canonical, type, consumes, repeatable).
690    // Empty when there's no schema, in which case every flag stays a bare flag
691    // (the old schema-blind behavior).
692    let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
693    let mut consumed: HashSet<usize> = HashSet::new();
694    let mut past_double_dash = false;
695
696    for i in 0..args.len() {
697        match &args[i] {
698            Arg::DoubleDash => past_double_dash = true,
699            Arg::Positional(expr) => {
700                if !consumed.contains(&i) {
701                    tool_args.positional.push(expr_to_placeholder(expr));
702                }
703            }
704            Arg::Named { key, value } => {
705                let v = expr_to_placeholder(value);
706                match param_lookup.get(key.as_str()) {
707                    // Repeatable `--flag=a --flag=b` accumulates (matches execute).
708                    Some(&(canonical, _, _, true)) => {
709                        let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
710                    }
711                    Some(&(canonical, ..)) => {
712                        tool_args.named.insert(canonical.to_string(), v);
713                    }
714                    None => {
715                        tool_args.named.insert(key.clone(), v);
716                    }
717                }
718            }
719            Arg::WordAssign { key, value } => {
720                // Validation walker doesn't know which command is receiving;
721                // route into named like the legacy behavior so checks stay
722                // consistent with previous validator output.
723                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
724            }
725            Arg::ShortFlag(name) => {
726                if past_double_dash {
727                    tool_args.positional.push(Value::String(format!("-{name}")));
728                } else {
729                    bind_short_flag_for_validation(
730                        name,
731                        &param_lookup,
732                        args,
733                        i,
734                        &mut consumed,
735                        &mut tool_args,
736                    );
737                }
738            }
739            Arg::LongFlag(name) => {
740                if past_double_dash {
741                    tool_args.positional.push(Value::String(format!("--{name}")));
742                } else {
743                    match param_lookup.get(name.as_str()) {
744                        Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
745                            bind_value_or_flag(
746                                &mut tool_args, name, canonical, consumes, repeatable, args, i,
747                                &mut consumed,
748                            );
749                        }
750                        Some(&(canonical, ..)) => {
751                            tool_args.flags.insert(canonical.to_string());
752                        }
753                        None => {
754                            tool_args.flags.insert(name.clone());
755                        }
756                    }
757                }
758            }
759        }
760    }
761
762    tool_args
763}
764
765/// Bind a (possibly glued/combined) short-flag token, schema-aware, mirroring
766/// `kernel::build_args_async`: a value-taking first char consumes the rest of the
767/// token as its glued value (`-e1d` → e=`1d`) or, if it is the last char, the next
768/// positional (`-e d` → e=`d`); bool flags stack (`-la`). Unknown chars stay bare
769/// flags so a schemaless tool keeps all-boolean behavior.
770fn bind_short_flag_for_validation(
771    name: &str,
772    param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
773    args: &[Arg],
774    i: usize,
775    consumed: &mut HashSet<usize>,
776    tool_args: &mut ToolArgs,
777) {
778    // Whole-name match first (POSIX `-name value` or a multi-char bool).
779    if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
780        if is_bool_type(typ) {
781            tool_args.flags.insert(canonical.to_string());
782        } else {
783            bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
784        }
785        return;
786    }
787    // First char is a declared value-taking short flag: the tail is its glued value.
788    if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
789        .get(&name[..1])
790        .filter(|(_, typ, ..)| !is_bool_type(typ))
791    {
792        let glued = name[1..].to_string();
793        if glued.is_empty() {
794            bind_value_or_flag(
795                tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
796            );
797        } else {
798            let _ =
799                bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
800        }
801        return;
802    }
803    // Combined short flags: bools stack until the first value-taking char, which
804    // consumes the rest of the token (or the next positional).
805    let bytes = name.as_bytes();
806    let mut p = 0;
807    while p < bytes.len() {
808        let key = &name[p..p + 1];
809        match param_lookup.get(key) {
810            Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
811                let glued = name[p + 1..].to_string();
812                if glued.is_empty() {
813                    bind_value_or_flag(
814                        tool_args, key, canonical, consumes, repeatable, args, i, consumed,
815                    );
816                } else {
817                    let _ = bind_glued_short_value(
818                        tool_args, key, canonical, consumes, repeatable, glued,
819                    );
820                }
821                return;
822            }
823            _ => {
824                tool_args.flags.insert(key.to_string());
825                p += 1;
826            }
827        }
828    }
829}
830
831/// Bind a bare value-flag by consuming the next `consumes` not-yet-consumed
832/// positionals as its value(s) — mirroring `kernel::consume_flag_positionals`:
833/// a single-value flag stores a scalar (repeatable → canonical array), a
834/// multi-value flag (`jq --arg NAME VALUE`, `consumes==2`) stores an
835/// array-of-arrays. A single-value flag may also consume a `key=value`
836/// (`awk -v a=1`); multi-value flags take plain positionals only. With nothing
837/// to consume it falls back to a bare flag.
838#[allow(clippy::too_many_arguments)] // mirrors kernel::consume_flag_positionals
839fn bind_value_or_flag(
840    tool_args: &mut ToolArgs,
841    flag_name: &str,
842    canonical: &str,
843    consumes: usize,
844    repeatable: bool,
845    args: &[Arg],
846    i: usize,
847    consumed: &mut HashSet<usize>,
848) {
849    let want = consumes.max(1);
850    let allow_word_assign = consumes <= 1;
851    let mut collected: Vec<Value> = Vec::with_capacity(want);
852    for _ in 0..want {
853        let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
854            let idx = i + 1 + off;
855            if consumed.contains(&idx) {
856                return None;
857            }
858            match a {
859                Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
860                Arg::WordAssign { key, value } if allow_word_assign => {
861                    let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
862                    Some((idx, Value::String(format!("{key}={s}"))))
863                }
864                _ => None,
865            }
866        });
867        match found {
868            Some((idx, v)) => {
869                consumed.insert(idx);
870                collected.push(v);
871            }
872            None => break,
873        }
874    }
875
876    if collected.is_empty() {
877        tool_args.flags.insert(canonical.to_string());
878        return;
879    }
880    if consumes <= 1 {
881        if let Some(v) = collected.into_iter().next() {
882            if repeatable {
883                let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
884            } else {
885                tool_args.named.insert(canonical.to_string(), v);
886            }
887        }
888        return;
889    }
890    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
891    let occ: Vec<serde_json::Value> = collected
892        .iter()
893        .map(crate::interpreter::value_to_json)
894        .collect();
895    let entry = tool_args
896        .named
897        .entry(canonical.to_string())
898        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
899    if let Value::Json(serde_json::Value::Array(outer)) = entry {
900        outer.push(serde_json::Value::Array(occ));
901    }
902}
903
904/// Convert an expression to a placeholder value for validation.
905///
906/// For literal values, return the actual value.
907/// For dynamic expressions (var refs, command subst), return a placeholder.
908fn expr_to_placeholder(expr: &Expr) -> Value {
909    match expr {
910        Expr::Literal(val) => val.clone(),
911        Expr::Interpolated(parts) if parts.len() == 1 => {
912            if let StringPart::Literal(s) = &parts[0] {
913                Value::String(s.clone())
914            } else {
915                Value::String("<dynamic>".to_string())
916            }
917        }
918        // For variable refs, command substitution, etc. - use placeholder
919        _ => Value::String("<dynamic>".to_string()),
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use crate::tools::{register_builtins, ToolRegistry};
927
928    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
929        let mut registry = ToolRegistry::new();
930        register_builtins(&mut registry);
931        let user_tools = HashMap::new();
932        (registry, user_tools)
933    }
934
935    #[test]
936    fn validates_undefined_command() {
937        let (registry, user_tools) = make_validator();
938        let validator = Validator::new(&registry, &user_tools);
939
940        let program = Program {
941            statements: vec![Stmt::Command(Command {
942                name: "nonexistent_command".to_string(),
943                args: vec![],
944                redirects: vec![],
945            })],
946        };
947
948        let issues = validator.validate(&program);
949        assert!(!issues.is_empty());
950        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
951    }
952
953    /// `test` is a first-class builtin now, so it validates through the
954    /// registry like any other command — no POSIX-conditional advisory, and no
955    /// spurious undefined-command warning.
956    #[test]
957    fn test_command_is_a_known_builtin() {
958        let (registry, user_tools) = make_validator();
959        let validator = Validator::new(&registry, &user_tools);
960
961        let program = Program {
962            statements: vec![Stmt::Command(Command {
963                name: "test".to_string(),
964                args: vec![
965                    Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
966                    Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
967                ],
968                redirects: vec![],
969            })],
970        };
971
972        let issues = validator.validate(&program);
973        assert!(
974            !issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
975            "`test` is a builtin — no undefined-command warning: {issues:?}"
976        );
977    }
978
979    #[test]
980    fn validates_known_command() {
981        let (registry, user_tools) = make_validator();
982        let validator = Validator::new(&registry, &user_tools);
983
984        let program = Program {
985            statements: vec![Stmt::Command(Command {
986                name: "echo".to_string(),
987                args: vec![Arg::Positional(Expr::Literal(Value::String(
988                    "hello".to_string(),
989                )))],
990                redirects: vec![],
991            })],
992        };
993
994        let issues = validator.validate(&program);
995        // echo should not produce an undefined command error
996        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
997    }
998
999    #[test]
1000    fn glued_value_flags_dont_false_error_at_validation() {
1001        // Regression for the schema-blind validation builder (docs/issues.md):
1002        // `sed -e1d -e2d FILE` must validate clean. Before schema-aware binding,
1003        // the glued `-e` values weren't split, so `collect_expressions` fell back
1004        // to parsing the FILE PATH as the sed program — a path-dependent false
1005        // E006 (here `file.txt` → its leading `f` is an "unknown command").
1006        let (registry, user_tools) = make_validator();
1007        let validator = Validator::new(&registry, &user_tools);
1008
1009        let program = Program {
1010            statements: vec![Stmt::Command(Command {
1011                name: "sed".to_string(),
1012                args: vec![
1013                    Arg::ShortFlag("e1d".to_string()),
1014                    Arg::ShortFlag("e2d".to_string()),
1015                    Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1016                ],
1017                redirects: vec![],
1018            })],
1019        };
1020
1021        let issues = validator.validate(&program);
1022        assert!(
1023            !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
1024            "glued -e flags false-errored at validation: {:?}",
1025            issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1026        );
1027    }
1028
1029    #[test]
1030    fn validates_break_outside_loop() {
1031        let (registry, user_tools) = make_validator();
1032        let validator = Validator::new(&registry, &user_tools);
1033
1034        let program = Program {
1035            statements: vec![Stmt::Break(None)],
1036        };
1037
1038        let issues = validator.validate(&program);
1039        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1040    }
1041
1042    #[test]
1043    fn validates_break_inside_loop() {
1044        let (registry, user_tools) = make_validator();
1045        let validator = Validator::new(&registry, &user_tools);
1046
1047        let program = Program {
1048            statements: vec![Stmt::For(ForLoop {
1049                variable: "i".to_string(),
1050                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
1051                body: vec![Stmt::Break(None)],
1052            })],
1053        };
1054
1055        let issues = validator.validate(&program);
1056        // Break inside loop should NOT produce an error
1057        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1058    }
1059
1060    #[test]
1061    fn validates_undefined_variable() {
1062        let (registry, user_tools) = make_validator();
1063        let validator = Validator::new(&registry, &user_tools);
1064
1065        let program = Program {
1066            statements: vec![Stmt::Command(Command {
1067                name: "echo".to_string(),
1068                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
1069                    "UNDEFINED_VAR",
1070                )))],
1071                redirects: vec![],
1072            })],
1073        };
1074
1075        let issues = validator.validate(&program);
1076        assert!(issues
1077            .iter()
1078            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1079    }
1080
1081    #[test]
1082    fn validates_defined_variable() {
1083        let (registry, user_tools) = make_validator();
1084        let validator = Validator::new(&registry, &user_tools);
1085
1086        let program = Program {
1087            statements: vec![
1088                // First assign the variable
1089                Stmt::Assignment(Assignment {
1090                    path: VarPath::simple("MY_VAR"),
1091                    value: Expr::Literal(Value::String("value".to_string())),
1092                    local: false,
1093                }),
1094                // Then use it
1095                Stmt::Command(Command {
1096                    name: "echo".to_string(),
1097                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
1098                    redirects: vec![],
1099                }),
1100            ],
1101        };
1102
1103        let issues = validator.validate(&program);
1104        // Should NOT warn about MY_VAR
1105        assert!(!issues
1106            .iter()
1107            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1108                && i.message.contains("MY_VAR")));
1109    }
1110
1111    #[test]
1112    fn skips_underscore_prefixed_vars() {
1113        let (registry, user_tools) = make_validator();
1114        let validator = Validator::new(&registry, &user_tools);
1115
1116        let program = Program {
1117            statements: vec![Stmt::Command(Command {
1118                name: "echo".to_string(),
1119                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1120                redirects: vec![],
1121            })],
1122        };
1123
1124        let issues = validator.validate(&program);
1125        // Should NOT warn about _EXTERNAL
1126        assert!(!issues
1127            .iter()
1128            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1129    }
1130
1131    #[test]
1132    fn builtin_vars_are_defined() {
1133        let (registry, user_tools) = make_validator();
1134        let validator = Validator::new(&registry, &user_tools);
1135
1136        let program = Program {
1137            statements: vec![Stmt::Command(Command {
1138                name: "echo".to_string(),
1139                args: vec![
1140                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1141                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1142                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1143                ],
1144                redirects: vec![],
1145            })],
1146        };
1147
1148        let issues = validator.validate(&program);
1149        // Should NOT warn about HOME, PATH, PWD
1150        assert!(!issues
1151            .iter()
1152            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1153    }
1154
1155    #[test]
1156    fn validates_scatter_without_gather() {
1157        let (registry, user_tools) = make_validator();
1158        let validator = Validator::new(&registry, &user_tools);
1159
1160        let program = Program {
1161            statements: vec![Stmt::Pipeline(Pipeline {
1162                commands: vec![
1163                    Command { name: "seq".to_string(), args: vec![
1164                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
1165                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
1166                    ], redirects: vec![] },
1167                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1168                    Command { name: "echo".to_string(), args: vec![
1169                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1170                    ], redirects: vec![] },
1171                ],
1172                background: false,
1173            })],
1174        };
1175
1176        let issues = validator.validate(&program);
1177        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1178            "should flag scatter without gather: {:?}", issues);
1179    }
1180
1181    #[test]
1182    fn allows_scatter_with_gather() {
1183        let (registry, user_tools) = make_validator();
1184        let validator = Validator::new(&registry, &user_tools);
1185
1186        let program = Program {
1187            statements: vec![Stmt::Pipeline(Pipeline {
1188                commands: vec![
1189                    Command { name: "seq".to_string(), args: vec![
1190                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
1191                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
1192                    ], redirects: vec![] },
1193                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1194                    Command { name: "echo".to_string(), args: vec![
1195                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1196                    ], redirects: vec![] },
1197                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1198                ],
1199                background: false,
1200            })],
1201        };
1202
1203        let issues = validator.validate(&program);
1204        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1205            "scatter with gather should pass: {:?}", issues);
1206    }
1207
1208    fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1209        let mut user_tools = HashMap::new();
1210        user_tools.insert(
1211            "mytool".to_string(),
1212            ToolDef {
1213                name: "mytool".to_string(),
1214                params: vec![crate::ast::ParamDef {
1215                    name: "input".to_string(),
1216                    param_type: None,
1217                    default: None,
1218                }],
1219                body: vec![],
1220            },
1221        );
1222        user_tools
1223    }
1224
1225    /// `mytool foo=bar` should count the bareword `key=value` as a positional
1226    /// because user tools aren't on the WordAssign allowlist — runtime
1227    /// stringifies WordAssign to a positional. Validator must agree.
1228    #[test]
1229    fn user_tool_wordassign_counts_as_positional() {
1230        let mut registry = ToolRegistry::new();
1231        register_builtins(&mut registry);
1232        let user_tools = make_user_tool_with_required_positional();
1233        let validator = Validator::new(&registry, &user_tools);
1234
1235        let program = Program {
1236            statements: vec![Stmt::Command(Command {
1237                name: "mytool".to_string(),
1238                args: vec![Arg::WordAssign {
1239                    key: "foo".to_string(),
1240                    value: Expr::Literal(Value::String("bar".to_string())),
1241                }],
1242                redirects: vec![],
1243            })],
1244        };
1245
1246        let issues = validator.validate(&program);
1247        assert!(
1248            !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1249            "WordAssign should satisfy required positional; got {:?}",
1250            issues
1251        );
1252    }
1253
1254    /// Missing-required-arg still fires when no positional or WordAssign is
1255    /// provided — regression guard so the fix doesn't silently skip the check.
1256    #[test]
1257    fn user_tool_no_args_still_errors() {
1258        let mut registry = ToolRegistry::new();
1259        register_builtins(&mut registry);
1260        let user_tools = make_user_tool_with_required_positional();
1261        let validator = Validator::new(&registry, &user_tools);
1262
1263        let program = Program {
1264            statements: vec![Stmt::Command(Command {
1265                name: "mytool".to_string(),
1266                args: vec![],
1267                redirects: vec![],
1268            })],
1269        };
1270
1271        let issues = validator.validate(&program);
1272        assert!(
1273            issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1274            "missing positional should still error; got {:?}",
1275            issues
1276        );
1277    }
1278}