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