Skip to main content

kaish_kernel/validator/
walker.rs

1//! AST walker for pre-execution validation.
2
3use std::collections::HashMap;
4
5use crate::ast::{
6    Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, Pipeline, Program,
7    Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop, Value,
8};
9use crate::tools::{ToolArgs, ToolRegistry};
10
11use super::issue::{IssueCode, ValidationIssue};
12use super::scope_tracker::ScopeTracker;
13
14/// AST validator that checks for issues before execution.
15pub struct Validator<'a> {
16    /// Reference to the tool registry.
17    registry: &'a ToolRegistry,
18    /// User-defined tools.
19    user_tools: &'a HashMap<String, ToolDef>,
20    /// Variable scope tracker.
21    scope: ScopeTracker,
22    /// Current loop nesting depth.
23    loop_depth: usize,
24    /// Current function nesting depth.
25    function_depth: usize,
26    /// Collected validation issues.
27    issues: Vec<ValidationIssue>,
28}
29
30impl<'a> Validator<'a> {
31    /// Create a new validator.
32    pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
33        Self {
34            registry,
35            user_tools,
36            scope: ScopeTracker::new(),
37            loop_depth: 0,
38            function_depth: 0,
39            issues: Vec::new(),
40        }
41    }
42
43    /// Validate a program and return all issues found.
44    pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
45        for stmt in &program.statements {
46            self.validate_stmt(stmt);
47        }
48        self.issues
49    }
50
51    /// Validate a single statement.
52    fn validate_stmt(&mut self, stmt: &Stmt) {
53        match stmt {
54            Stmt::Assignment(assign) => self.validate_assignment(assign),
55            Stmt::Command(cmd) => self.validate_command(cmd),
56            Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
57            Stmt::If(if_stmt) => self.validate_if(if_stmt),
58            Stmt::For(for_loop) => self.validate_for(for_loop),
59            Stmt::While(while_loop) => self.validate_while(while_loop),
60            Stmt::Case(case_stmt) => self.validate_case(case_stmt),
61            Stmt::Break(levels) => self.validate_break(*levels),
62            Stmt::Continue(levels) => self.validate_continue(*levels),
63            Stmt::Return(expr) => self.validate_return(expr.as_deref()),
64            Stmt::Exit(expr) => {
65                if let Some(e) = expr {
66                    self.validate_expr(e);
67                }
68            }
69            Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
70            Stmt::Test(test_expr) => self.validate_test(test_expr),
71            Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
72                self.validate_stmt(left);
73                self.validate_stmt(right);
74            }
75            Stmt::Empty => {}
76        }
77    }
78
79    /// Validate an assignment statement.
80    fn validate_assignment(&mut self, assign: &Assignment) {
81        // Validate the value expression
82        self.validate_expr(&assign.value);
83        // Bind the variable name in scope
84        self.scope.bind(&assign.name);
85    }
86
87    /// Validate a command invocation.
88    fn validate_command(&mut self, cmd: &Command) {
89        // Skip source/. commands - they're dynamic
90        if cmd.name == "source" || cmd.name == "." {
91            return;
92        }
93
94        // Skip dynamic command names (variable expansions)
95        if !is_static_command_name(&cmd.name) {
96            return;
97        }
98
99        // Check if command exists
100        let is_builtin = self.registry.contains(&cmd.name);
101        let is_user_tool = self.user_tools.contains_key(&cmd.name);
102        let is_special = is_special_command(&cmd.name);
103
104        if !is_builtin && !is_user_tool && !is_special {
105            // Warning only - command might be a script in PATH or external tool
106            self.issues.push(ValidationIssue::warning(
107                IssueCode::UndefinedCommand,
108                format!("command '{}' not found in builtin registry", cmd.name),
109            ).with_suggestion("this may be a script in PATH or external command"));
110        }
111
112        // Validate arguments expressions
113        for arg in &cmd.args {
114            self.validate_arg(arg);
115        }
116
117        // Check for shell glob patterns in arguments (unless command expects patterns)
118        if !command_expects_pattern_or_text(&cmd.name) {
119            for arg in &cmd.args {
120                if let Arg::Positional(expr) = arg
121                    && let Some(pattern) = self.extract_unquoted_glob_pattern(expr) {
122                        self.issues.push(
123                            ValidationIssue::error(
124                                IssueCode::ShellGlobPattern,
125                                format!(
126                                    "glob pattern '{}' won't expand (kaish has no implicit globbing)",
127                                    pattern
128                                ),
129                            )
130                            .with_suggestion(
131                                "use: glob \"pattern\" | xargs cmd  OR  for f in $(glob \"pattern\")",
132                            ),
133                        );
134                    }
135            }
136        }
137
138        // If we have a schema, validate args against it
139        if let Some(tool) = self.registry.get(&cmd.name) {
140            let tool_args = build_tool_args_for_validation(&cmd.args);
141            let tool_issues = tool.validate(&tool_args);
142            self.issues.extend(tool_issues);
143        } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
144            // Validate against user-defined tool parameters
145            self.validate_user_tool_args(user_tool, &cmd.args);
146        }
147
148        // Validate redirects
149        for redirect in &cmd.redirects {
150            self.validate_expr(&redirect.target);
151        }
152    }
153
154    /// Validate a command argument.
155    fn validate_arg(&mut self, arg: &Arg) {
156        match arg {
157            Arg::Positional(expr) => self.validate_expr(expr),
158            Arg::Named { value, .. } => self.validate_expr(value),
159            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
160        }
161    }
162
163    /// Validate a pipeline.
164    fn validate_pipeline(&mut self, pipe: &Pipeline) {
165        // Check for scatter without gather
166        let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
167        let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
168        if has_scatter && !has_gather {
169            self.issues.push(
170                ValidationIssue::error(
171                    IssueCode::ScatterWithoutGather,
172                    "scatter without gather — parallel results would be lost",
173                ).with_suggestion("add gather: ... | scatter | cmd | gather")
174            );
175        }
176
177        for cmd in &pipe.commands {
178            self.validate_command(cmd);
179        }
180    }
181
182    /// Validate an if statement.
183    fn validate_if(&mut self, if_stmt: &IfStmt) {
184        self.validate_expr(&if_stmt.condition);
185
186        self.scope.push_frame();
187        for stmt in &if_stmt.then_branch {
188            self.validate_stmt(stmt);
189        }
190        self.scope.pop_frame();
191
192        if let Some(else_branch) = &if_stmt.else_branch {
193            self.scope.push_frame();
194            for stmt in else_branch {
195                self.validate_stmt(stmt);
196            }
197            self.scope.pop_frame();
198        }
199    }
200
201    /// Validate a for loop.
202    fn validate_for(&mut self, for_loop: &ForLoop) {
203        // Validate item expressions and check for bare scalar variables
204        for item in &for_loop.items {
205            self.validate_expr(item);
206
207            // Detect `for i in $VAR` pattern - always a mistake in kaish
208            // since we don't do implicit word splitting
209            if self.is_bare_scalar_var(item) {
210                self.issues.push(
211                    ValidationIssue::error(
212                        IssueCode::ForLoopScalarVar,
213                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
214                    )
215                    .with_suggestion(concat!(
216                        "use one of:\n",
217                        "    for i in $(split \"$VAR\")      # split on whitespace\n",
218                        "    for i in $(split \"$VAR\" \":\")  # split on delimiter\n",
219                        "    for i in $(seq 1 10)          # iterate numbers\n",
220                        "    for i in $(glob \"*.rs\")       # iterate files",
221                    )),
222                );
223            }
224        }
225
226        self.loop_depth += 1;
227        self.scope.push_frame();
228
229        // Bind loop variable
230        self.scope.bind(&for_loop.variable);
231
232        for stmt in &for_loop.body {
233            self.validate_stmt(stmt);
234        }
235
236        self.scope.pop_frame();
237        self.loop_depth -= 1;
238    }
239
240    /// Extract glob pattern from unquoted literal expressions.
241    ///
242    /// Returns `Some(pattern)` if the expression is an unquoted literal that
243    /// looks like a shell glob pattern (e.g., `*.txt`, `file?.log`).
244    fn extract_unquoted_glob_pattern(&self, expr: &Expr) -> Option<String> {
245        match expr {
246            Expr::Literal(Value::String(s)) if looks_like_shell_glob(s) => Some(s.clone()),
247            // Interpolated strings that are just a literal (parser may produce these)
248            Expr::Interpolated(parts) if parts.len() == 1 => {
249                if let StringPart::Literal(s) = &parts[0]
250                    && looks_like_shell_glob(s) {
251                        return Some(s.clone());
252                    }
253                None
254            }
255            _ => None,
256        }
257    }
258
259    /// Check if an expression is a bare scalar variable reference.
260    ///
261    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
262    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
263        match expr {
264            // Direct variable reference like $VAR or ${VAR}
265            Expr::VarRef(_) => true,
266            // Variable with default like ${VAR:-default} - also problematic
267            Expr::VarWithDefault { .. } => true,
268            // NOT a problem: command substitution like $(cmd) - returns structured data
269            Expr::CommandSubst(_) => false,
270            // NOT a problem: literals are fine
271            Expr::Literal(_) => false,
272            // NOT a problem: interpolated strings are a single value
273            Expr::Interpolated(_) => false,
274            // Everything else: not a bare scalar var
275            _ => false,
276        }
277    }
278
279    /// Validate a while loop.
280    fn validate_while(&mut self, while_loop: &WhileLoop) {
281        self.validate_expr(&while_loop.condition);
282
283        self.loop_depth += 1;
284        self.scope.push_frame();
285
286        for stmt in &while_loop.body {
287            self.validate_stmt(stmt);
288        }
289
290        self.scope.pop_frame();
291        self.loop_depth -= 1;
292    }
293
294    /// Validate a case statement.
295    fn validate_case(&mut self, case_stmt: &CaseStmt) {
296        self.validate_expr(&case_stmt.expr);
297
298        for branch in &case_stmt.branches {
299            self.validate_case_branch(branch);
300        }
301    }
302
303    /// Validate a case branch.
304    fn validate_case_branch(&mut self, branch: &CaseBranch) {
305        self.scope.push_frame();
306        for stmt in &branch.body {
307            self.validate_stmt(stmt);
308        }
309        self.scope.pop_frame();
310    }
311
312    /// Validate a break statement.
313    fn validate_break(&mut self, levels: Option<usize>) {
314        if self.loop_depth == 0 {
315            self.issues.push(ValidationIssue::error(
316                IssueCode::BreakOutsideLoop,
317                "break used outside of a loop",
318            ));
319        } else if let Some(n) = levels
320            && n > self.loop_depth {
321                self.issues.push(ValidationIssue::warning(
322                    IssueCode::BreakOutsideLoop,
323                    format!(
324                        "break {} exceeds loop nesting depth {}",
325                        n, self.loop_depth
326                    ),
327                ));
328            }
329    }
330
331    /// Validate a continue statement.
332    fn validate_continue(&mut self, levels: Option<usize>) {
333        if self.loop_depth == 0 {
334            self.issues.push(ValidationIssue::error(
335                IssueCode::BreakOutsideLoop,
336                "continue used outside of a loop",
337            ));
338        } else if let Some(n) = levels
339            && n > self.loop_depth {
340                self.issues.push(ValidationIssue::warning(
341                    IssueCode::BreakOutsideLoop,
342                    format!(
343                        "continue {} exceeds loop nesting depth {}",
344                        n, self.loop_depth
345                    ),
346                ));
347            }
348    }
349
350    /// Validate a return statement.
351    fn validate_return(&mut self, expr: Option<&Expr>) {
352        if let Some(e) = expr {
353            self.validate_expr(e);
354        }
355
356        if self.function_depth == 0 {
357            self.issues.push(ValidationIssue::error(
358                IssueCode::ReturnOutsideFunction,
359                "return used outside of a function",
360            ));
361        }
362    }
363
364    /// Validate a tool definition.
365    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
366        self.function_depth += 1;
367        self.scope.push_frame();
368
369        // Bind parameters
370        for param in &tool_def.params {
371            self.scope.bind(&param.name);
372            // Validate default expressions
373            if let Some(default) = &param.default {
374                self.validate_expr(default);
375            }
376        }
377
378        // Validate body
379        for stmt in &tool_def.body {
380            self.validate_stmt(stmt);
381        }
382
383        self.scope.pop_frame();
384        self.function_depth -= 1;
385    }
386
387    /// Validate a test expression.
388    fn validate_test(&mut self, test: &TestExpr) {
389        match test {
390            TestExpr::FileTest { path, .. } => self.validate_expr(path),
391            TestExpr::StringTest { value, .. } => self.validate_expr(value),
392            TestExpr::Comparison { left, right, .. } => {
393                self.validate_expr(left);
394                self.validate_expr(right);
395            }
396            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
397                self.validate_test(left);
398                self.validate_test(right);
399            }
400            TestExpr::Not { expr } => self.validate_test(expr),
401        }
402    }
403
404    /// Validate an expression.
405    fn validate_expr(&mut self, expr: &Expr) {
406        match expr {
407            Expr::Literal(_) => {}
408            Expr::VarRef(path) => self.validate_var_ref(path),
409            Expr::Interpolated(parts) => {
410                for part in parts {
411                    self.validate_string_part(part);
412                }
413            }
414            Expr::BinaryOp { left, right, .. } => {
415                self.validate_expr(left);
416                self.validate_expr(right);
417            }
418            Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
419            Expr::Test(test) => self.validate_test(test),
420            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
421            Expr::VarLength(name) => self.check_var_defined(name),
422            Expr::VarWithDefault { name, .. } => {
423                // Don't warn - default handles undefined case
424                let _ = name;
425            }
426            Expr::Arithmetic(_) => {
427                // Arithmetic parsing is done at runtime
428            }
429            Expr::Command(cmd) => self.validate_command(cmd),
430            Expr::LastExitCode | Expr::CurrentPid => {}
431        }
432    }
433
434    /// Validate a variable reference.
435    fn validate_var_ref(&mut self, path: &VarPath) {
436        if let Some(VarSegment::Field(name)) = path.segments.first() {
437            self.check_var_defined(name);
438        }
439    }
440
441    /// Validate a string interpolation part.
442    fn validate_string_part(&mut self, part: &StringPart) {
443        match part {
444            StringPart::Literal(_) => {}
445            StringPart::Var(path) => self.validate_var_ref(path),
446            StringPart::VarWithDefault { default, .. } => {
447                // Validate nested parts in the default value
448                for p in default {
449                    self.validate_string_part(p);
450                }
451            }
452            StringPart::VarLength(name) => self.check_var_defined(name),
453            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
454            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
455            StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
456            StringPart::LastExitCode | StringPart::CurrentPid => {}
457        }
458    }
459
460    /// Check if a variable is defined and warn if not.
461    fn check_var_defined(&mut self, name: &str) {
462        // Skip underscore-prefixed vars (external/unchecked convention)
463        if ScopeTracker::should_skip_undefined_check(name) {
464            return;
465        }
466
467        if !self.scope.is_bound(name) {
468            self.issues.push(ValidationIssue::warning(
469                IssueCode::PossiblyUndefinedVariable,
470                format!("variable '{}' may be undefined", name),
471            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
472        }
473    }
474
475    /// Validate arguments against a user-defined tool's parameters.
476    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
477        let positional_count = args
478            .iter()
479            .filter(|a| matches!(a, Arg::Positional(_)))
480            .count();
481
482        let required_count = tool_def
483            .params
484            .iter()
485            .filter(|p| p.default.is_none())
486            .count();
487
488        if positional_count < required_count {
489            self.issues.push(ValidationIssue::error(
490                IssueCode::MissingRequiredArg,
491                format!(
492                    "'{}' requires {} arguments, got {}",
493                    tool_def.name, required_count, positional_count
494                ),
495            ));
496        }
497    }
498}
499
500/// Check if a command name is static (not a variable expansion).
501fn is_static_command_name(name: &str) -> bool {
502    !name.starts_with('$') && !name.contains("$(")
503}
504
505/// Check if a string looks like a shell glob pattern.
506///
507/// Detects: `*`, `?`, `[x]`, `[a-z]`, etc. when they appear in what looks
508/// like a filename pattern.
509fn looks_like_shell_glob(s: &str) -> bool {
510    // Skip if it looks like a regex anchor or common non-glob uses
511    if s.starts_with('^') || s.ends_with('$') {
512        return false;
513    }
514
515    let has_star = s.contains('*');
516    let has_question = s.contains('?') && !s.contains("??"); // ?? is often intentional
517    let has_bracket = s.contains('[') && s.contains(']');
518
519    // Must look like a filename pattern (has extension or path separator)
520    let looks_like_path = s.contains('.') || s.contains('/');
521
522    (has_star || has_question || has_bracket) && looks_like_path
523}
524
525/// Check if a command expects pattern arguments or text (not filenames).
526///
527/// Returns true for commands where glob-like patterns in arguments are
528/// intentional and shouldn't trigger validation warnings.
529fn command_expects_pattern_or_text(cmd: &str) -> bool {
530    matches!(
531        cmd,
532        // Pattern-based commands
533        "grep" | "egrep" | "fgrep" | "sed" | "awk" | "find" | "glob" | "regex"
534        // Text output commands - anything is valid text
535        | "echo" | "printf"
536        // JSON/text processing
537        | "jq"
538    )
539}
540
541/// Check if a command is a special built-in that we don't validate.
542fn is_special_command(name: &str) -> bool {
543    matches!(
544        name,
545        "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
546    )
547}
548
549/// Build ToolArgs from AST Args for validation purposes.
550///
551/// This is a simplified version that doesn't evaluate expressions -
552/// it uses placeholder values since we only care about argument structure.
553pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
554    let mut tool_args = ToolArgs::new();
555
556    for arg in args {
557        match arg {
558            Arg::Positional(expr) => {
559                tool_args.positional.push(expr_to_placeholder(expr));
560            }
561            Arg::Named { key, value } => {
562                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
563            }
564            Arg::ShortFlag(flag) => {
565                tool_args.flags.insert(flag.clone());
566            }
567            Arg::LongFlag(flag) => {
568                tool_args.flags.insert(flag.clone());
569            }
570            Arg::DoubleDash => {}
571        }
572    }
573
574    tool_args
575}
576
577/// Convert an expression to a placeholder value for validation.
578///
579/// For literal values, return the actual value.
580/// For dynamic expressions (var refs, command subst), return a placeholder.
581fn expr_to_placeholder(expr: &Expr) -> Value {
582    match expr {
583        Expr::Literal(val) => val.clone(),
584        Expr::Interpolated(parts) if parts.len() == 1 => {
585            if let StringPart::Literal(s) = &parts[0] {
586                Value::String(s.clone())
587            } else {
588                Value::String("<dynamic>".to_string())
589            }
590        }
591        // For variable refs, command substitution, etc. - use placeholder
592        _ => Value::String("<dynamic>".to_string()),
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use crate::tools::{register_builtins, ToolRegistry};
600
601    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
602        let mut registry = ToolRegistry::new();
603        register_builtins(&mut registry);
604        let user_tools = HashMap::new();
605        (registry, user_tools)
606    }
607
608    #[test]
609    fn validates_undefined_command() {
610        let (registry, user_tools) = make_validator();
611        let validator = Validator::new(&registry, &user_tools);
612
613        let program = Program {
614            statements: vec![Stmt::Command(Command {
615                name: "nonexistent_command".to_string(),
616                args: vec![],
617                redirects: vec![],
618            })],
619        };
620
621        let issues = validator.validate(&program);
622        assert!(!issues.is_empty());
623        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
624    }
625
626    #[test]
627    fn validates_known_command() {
628        let (registry, user_tools) = make_validator();
629        let validator = Validator::new(&registry, &user_tools);
630
631        let program = Program {
632            statements: vec![Stmt::Command(Command {
633                name: "echo".to_string(),
634                args: vec![Arg::Positional(Expr::Literal(Value::String(
635                    "hello".to_string(),
636                )))],
637                redirects: vec![],
638            })],
639        };
640
641        let issues = validator.validate(&program);
642        // echo should not produce an undefined command error
643        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
644    }
645
646    #[test]
647    fn validates_break_outside_loop() {
648        let (registry, user_tools) = make_validator();
649        let validator = Validator::new(&registry, &user_tools);
650
651        let program = Program {
652            statements: vec![Stmt::Break(None)],
653        };
654
655        let issues = validator.validate(&program);
656        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
657    }
658
659    #[test]
660    fn validates_break_inside_loop() {
661        let (registry, user_tools) = make_validator();
662        let validator = Validator::new(&registry, &user_tools);
663
664        let program = Program {
665            statements: vec![Stmt::For(ForLoop {
666                variable: "i".to_string(),
667                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
668                body: vec![Stmt::Break(None)],
669            })],
670        };
671
672        let issues = validator.validate(&program);
673        // Break inside loop should NOT produce an error
674        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
675    }
676
677    #[test]
678    fn validates_undefined_variable() {
679        let (registry, user_tools) = make_validator();
680        let validator = Validator::new(&registry, &user_tools);
681
682        let program = Program {
683            statements: vec![Stmt::Command(Command {
684                name: "echo".to_string(),
685                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
686                    "UNDEFINED_VAR",
687                )))],
688                redirects: vec![],
689            })],
690        };
691
692        let issues = validator.validate(&program);
693        assert!(issues
694            .iter()
695            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
696    }
697
698    #[test]
699    fn validates_defined_variable() {
700        let (registry, user_tools) = make_validator();
701        let validator = Validator::new(&registry, &user_tools);
702
703        let program = Program {
704            statements: vec![
705                // First assign the variable
706                Stmt::Assignment(Assignment {
707                    name: "MY_VAR".to_string(),
708                    value: Expr::Literal(Value::String("value".to_string())),
709                    local: false,
710                }),
711                // Then use it
712                Stmt::Command(Command {
713                    name: "echo".to_string(),
714                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
715                    redirects: vec![],
716                }),
717            ],
718        };
719
720        let issues = validator.validate(&program);
721        // Should NOT warn about MY_VAR
722        assert!(!issues
723            .iter()
724            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
725                && i.message.contains("MY_VAR")));
726    }
727
728    #[test]
729    fn skips_underscore_prefixed_vars() {
730        let (registry, user_tools) = make_validator();
731        let validator = Validator::new(&registry, &user_tools);
732
733        let program = Program {
734            statements: vec![Stmt::Command(Command {
735                name: "echo".to_string(),
736                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
737                redirects: vec![],
738            })],
739        };
740
741        let issues = validator.validate(&program);
742        // Should NOT warn about _EXTERNAL
743        assert!(!issues
744            .iter()
745            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
746    }
747
748    #[test]
749    fn builtin_vars_are_defined() {
750        let (registry, user_tools) = make_validator();
751        let validator = Validator::new(&registry, &user_tools);
752
753        let program = Program {
754            statements: vec![Stmt::Command(Command {
755                name: "echo".to_string(),
756                args: vec![
757                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
758                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
759                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
760                ],
761                redirects: vec![],
762            })],
763        };
764
765        let issues = validator.validate(&program);
766        // Should NOT warn about HOME, PATH, PWD
767        assert!(!issues
768            .iter()
769            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
770    }
771
772    #[test]
773    fn looks_like_shell_glob_detects_star() {
774        assert!(looks_like_shell_glob("*.txt"));
775        assert!(looks_like_shell_glob("src/*.rs"));
776        assert!(looks_like_shell_glob("**/*.json"));
777    }
778
779    #[test]
780    fn looks_like_shell_glob_detects_question() {
781        assert!(looks_like_shell_glob("file?.log"));
782        assert!(looks_like_shell_glob("test?.txt"));
783    }
784
785    #[test]
786    fn looks_like_shell_glob_detects_brackets() {
787        assert!(looks_like_shell_glob("[abc].txt"));
788        assert!(looks_like_shell_glob("[a-z].log"));
789        assert!(looks_like_shell_glob("file[0-9].rs"));
790    }
791
792    #[test]
793    fn looks_like_shell_glob_rejects_non_patterns() {
794        // No wildcard chars
795        assert!(!looks_like_shell_glob("readme.txt"));
796        assert!(!looks_like_shell_glob("src/main.rs"));
797        // Has wildcard but no extension/path (not filename-like)
798        assert!(!looks_like_shell_glob("hello*world"));
799        // Regex anchors (not globs)
800        assert!(!looks_like_shell_glob("^start"));
801        assert!(!looks_like_shell_glob("end$"));
802    }
803
804    #[test]
805    fn command_expects_pattern_or_text_returns_true() {
806        assert!(command_expects_pattern_or_text("grep"));
807        assert!(command_expects_pattern_or_text("echo"));
808        assert!(command_expects_pattern_or_text("find"));
809        assert!(command_expects_pattern_or_text("glob"));
810    }
811
812    #[test]
813    fn command_expects_pattern_or_text_returns_false() {
814        assert!(!command_expects_pattern_or_text("ls"));
815        assert!(!command_expects_pattern_or_text("cat"));
816        assert!(!command_expects_pattern_or_text("rm"));
817        assert!(!command_expects_pattern_or_text("cp"));
818    }
819
820    #[test]
821    fn validates_glob_pattern_in_ls() {
822        let (registry, user_tools) = make_validator();
823        let validator = Validator::new(&registry, &user_tools);
824
825        let program = Program {
826            statements: vec![Stmt::Command(Command {
827                name: "ls".to_string(),
828                args: vec![Arg::Positional(Expr::Literal(Value::String(
829                    "*.txt".to_string(),
830                )))],
831                redirects: vec![],
832            })],
833        };
834
835        let issues = validator.validate(&program);
836        assert!(issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
837    }
838
839    #[test]
840    fn allows_glob_pattern_in_grep() {
841        let (registry, user_tools) = make_validator();
842        let validator = Validator::new(&registry, &user_tools);
843
844        let program = Program {
845            statements: vec![Stmt::Command(Command {
846                name: "grep".to_string(),
847                args: vec![Arg::Positional(Expr::Literal(Value::String(
848                    "func.*test".to_string(),
849                )))],
850                redirects: vec![],
851            })],
852        };
853
854        let issues = validator.validate(&program);
855        // grep expects patterns, so should NOT warn
856        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
857    }
858
859    #[test]
860    fn allows_glob_pattern_in_echo() {
861        let (registry, user_tools) = make_validator();
862        let validator = Validator::new(&registry, &user_tools);
863
864        let program = Program {
865            statements: vec![Stmt::Command(Command {
866                name: "echo".to_string(),
867                args: vec![Arg::Positional(Expr::Literal(Value::String(
868                    "*.txt".to_string(),
869                )))],
870                redirects: vec![],
871            })],
872        };
873
874        let issues = validator.validate(&program);
875        // echo is text output, so should NOT warn
876        assert!(!issues.iter().any(|i| i.code == IssueCode::ShellGlobPattern));
877    }
878
879    #[test]
880    fn validates_scatter_without_gather() {
881        let (registry, user_tools) = make_validator();
882        let validator = Validator::new(&registry, &user_tools);
883
884        let program = Program {
885            statements: vec![Stmt::Pipeline(Pipeline {
886                commands: vec![
887                    Command { name: "seq".to_string(), args: vec![
888                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
889                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
890                    ], redirects: vec![] },
891                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
892                    Command { name: "echo".to_string(), args: vec![
893                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
894                    ], redirects: vec![] },
895                ],
896                background: false,
897            })],
898        };
899
900        let issues = validator.validate(&program);
901        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
902            "should flag scatter without gather: {:?}", issues);
903    }
904
905    #[test]
906    fn allows_scatter_with_gather() {
907        let (registry, user_tools) = make_validator();
908        let validator = Validator::new(&registry, &user_tools);
909
910        let program = Program {
911            statements: vec![Stmt::Pipeline(Pipeline {
912                commands: vec![
913                    Command { name: "seq".to_string(), args: vec![
914                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
915                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
916                    ], redirects: vec![] },
917                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
918                    Command { name: "echo".to_string(), args: vec![
919                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
920                    ], redirects: vec![] },
921                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
922                ],
923                background: false,
924            })],
925        };
926
927        let issues = validator.validate(&program);
928        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
929            "scatter with gather should pass: {:?}", issues);
930    }
931}