Skip to main content

kaish_kernel/validator/
issue.rs

1//! Validation issues and formatting.
2
3use std::fmt;
4
5/// Severity level for validation issues.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Severity {
8    /// Errors prevent execution.
9    Error,
10    /// Warnings are advisory but allow execution.
11    Warning,
12}
13
14impl fmt::Display for Severity {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            Severity::Error => write!(f, "error"),
18            Severity::Warning => write!(f, "warning"),
19        }
20    }
21}
22
23/// Categorizes validation issues for filtering and tooling.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum IssueCode {
26    /// Command not found in registry or user tools.
27    UndefinedCommand,
28    /// Required parameter not provided.
29    MissingRequiredArg,
30    /// Flag not defined in tool schema.
31    UnknownFlag,
32    /// Argument type doesn't match schema.
33    InvalidArgType,
34    /// seq increment is zero (infinite loop).
35    SeqZeroIncrement,
36    /// Regex pattern is invalid.
37    InvalidRegex,
38    /// sed expression is invalid.
39    InvalidSedExpr,
40    /// jq filter is invalid.
41    InvalidJqFilter,
42    /// break/continue outside of a loop.
43    BreakOutsideLoop,
44    /// return outside of a function.
45    ReturnOutsideFunction,
46    /// Variable may be undefined.
47    PossiblyUndefinedVariable,
48    /// Multiple conflicting flags.
49    ConflictingFlags,
50    /// count/lines value is zero or negative.
51    InvalidCount,
52    /// diff needs two files.
53    DiffNeedsTwoFiles,
54    /// Recursive operation without -r flag.
55    RecursiveWithoutFlag,
56    /// Extra positional arguments beyond what tool accepts.
57    ExtraPositionalArgs,
58    /// Bare scalar variable in for loop (no word splitting in kaish).
59    ForLoopScalarVar,
60    /// Unquoted glob pattern that won't expand (kaish has no implicit globbing).
61    ShellGlobPattern,
62}
63
64impl IssueCode {
65    /// Returns a short code string for the issue.
66    pub fn code(&self) -> &'static str {
67        match self {
68            IssueCode::UndefinedCommand => "E001",
69            IssueCode::MissingRequiredArg => "E002",
70            IssueCode::UnknownFlag => "W001",
71            IssueCode::InvalidArgType => "E003",
72            IssueCode::SeqZeroIncrement => "E004",
73            IssueCode::InvalidRegex => "E005",
74            IssueCode::InvalidSedExpr => "E006",
75            IssueCode::InvalidJqFilter => "E007",
76            IssueCode::BreakOutsideLoop => "E008",
77            IssueCode::ReturnOutsideFunction => "E009",
78            IssueCode::PossiblyUndefinedVariable => "W002",
79            IssueCode::ConflictingFlags => "W003",
80            IssueCode::InvalidCount => "E010",
81            IssueCode::DiffNeedsTwoFiles => "E011",
82            IssueCode::RecursiveWithoutFlag => "W004",
83            IssueCode::ExtraPositionalArgs => "W005",
84            IssueCode::ForLoopScalarVar => "E012",
85            IssueCode::ShellGlobPattern => "E013",
86        }
87    }
88
89    /// Default severity for this issue code.
90    pub fn default_severity(&self) -> Severity {
91        match self {
92            // These are hard errors that will definitely fail at runtime
93            IssueCode::SeqZeroIncrement
94            | IssueCode::InvalidRegex
95            | IssueCode::InvalidSedExpr
96            | IssueCode::InvalidJqFilter
97            | IssueCode::BreakOutsideLoop
98            | IssueCode::ReturnOutsideFunction
99            | IssueCode::InvalidCount
100            | IssueCode::DiffNeedsTwoFiles
101            | IssueCode::ForLoopScalarVar
102            | IssueCode::ShellGlobPattern => Severity::Error,
103
104            // These are warnings because context matters:
105            // - MissingRequiredArg: might be provided by pipeline stdin or environment
106            // - InvalidArgType: shell coerces types at runtime
107            // - UndefinedCommand: might be script in PATH or external tool
108            IssueCode::MissingRequiredArg
109            | IssueCode::InvalidArgType
110            | IssueCode::UndefinedCommand
111            | IssueCode::UnknownFlag
112            | IssueCode::PossiblyUndefinedVariable
113            | IssueCode::ConflictingFlags
114            | IssueCode::RecursiveWithoutFlag
115            | IssueCode::ExtraPositionalArgs => Severity::Warning,
116        }
117    }
118}
119
120impl fmt::Display for IssueCode {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(f, "{}", self.code())
123    }
124}
125
126/// Source location span.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub struct Span {
129    /// Start byte offset in source.
130    pub start: usize,
131    /// End byte offset in source.
132    pub end: usize,
133}
134
135impl Span {
136    /// Create a new span.
137    pub fn new(start: usize, end: usize) -> Self {
138        Self { start, end }
139    }
140
141    /// Convert byte offset to line:column.
142    ///
143    /// Returns (line, column) where both are 1-indexed.
144    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
145        let mut line = 1;
146        let mut col = 1;
147
148        for (i, ch) in source.char_indices() {
149            if i >= self.start {
150                break;
151            }
152            if ch == '\n' {
153                line += 1;
154                col = 1;
155            } else {
156                col += 1;
157            }
158        }
159
160        (line, col)
161    }
162
163    /// Format span as "line:col" string.
164    pub fn format_location(&self, source: &str) -> String {
165        let (line, col) = self.to_line_col(source);
166        format!("{}:{}", line, col)
167    }
168}
169
170/// A validation issue found in the script.
171#[derive(Debug, Clone)]
172pub struct ValidationIssue {
173    /// Severity level.
174    pub severity: Severity,
175    /// Issue category code.
176    pub code: IssueCode,
177    /// Human-readable message.
178    pub message: String,
179    /// Optional source location.
180    pub span: Option<Span>,
181    /// Optional suggestion for fixing the issue.
182    pub suggestion: Option<String>,
183}
184
185impl ValidationIssue {
186    /// Create a new validation error.
187    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
188        Self {
189            severity: Severity::Error,
190            code,
191            message: message.into(),
192            span: None,
193            suggestion: None,
194        }
195    }
196
197    /// Create a new validation warning.
198    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
199        Self {
200            severity: Severity::Warning,
201            code,
202            message: message.into(),
203            span: None,
204            suggestion: None,
205        }
206    }
207
208    /// Add a span to this issue.
209    pub fn with_span(mut self, span: Span) -> Self {
210        self.span = Some(span);
211        self
212    }
213
214    /// Add a suggestion to this issue.
215    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
216        self.suggestion = Some(suggestion.into());
217        self
218    }
219
220    /// Format the issue for display.
221    ///
222    /// With source provided, includes line:column information and source context.
223    pub fn format(&self, source: &str) -> String {
224        let mut result = String::new();
225
226        // Location prefix if we have a span
227        if let Some(span) = &self.span {
228            let loc = span.format_location(source);
229            result.push_str(&format!("{}: ", loc));
230        }
231
232        // Severity and code
233        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
234
235        // Suggestion if available
236        if let Some(suggestion) = &self.suggestion {
237            result.push_str(&format!("\n  → {}", suggestion));
238        }
239
240        // Source context if we have a span
241        if let Some(span) = &self.span
242            && let Some(line_content) = get_line_at_offset(source, span.start) {
243                result.push_str(&format!("\n  | {}", line_content));
244            }
245
246        result
247    }
248}
249
250impl fmt::Display for ValidationIssue {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
253    }
254}
255
256/// Get the line containing a byte offset.
257fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
258    if offset >= source.len() {
259        return None;
260    }
261
262    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
263    let end = source[offset..]
264        .find('\n')
265        .map_or(source.len(), |i| offset + i);
266
267    Some(&source[start..end])
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn span_to_line_col_single_line() {
276        let source = "echo hello world";
277        let span = Span::new(5, 10);
278        assert_eq!(span.to_line_col(source), (1, 6));
279    }
280
281    #[test]
282    fn span_to_line_col_multi_line() {
283        let source = "line one\nline two\nline three";
284        // "line" on line 3 starts at offset 18
285        let span = Span::new(18, 22);
286        assert_eq!(span.to_line_col(source), (3, 1));
287    }
288
289    #[test]
290    fn span_format_location() {
291        let source = "first\nsecond\nthird";
292        let span = Span::new(6, 12); // "second"
293        assert_eq!(span.format_location(source), "2:1");
294    }
295
296    #[test]
297    fn issue_formatting() {
298        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
299            .with_span(Span::new(0, 3))
300            .with_suggestion("did you mean 'for'?");
301
302        let source = "foo bar";
303        let formatted = issue.format(source);
304
305        assert!(formatted.contains("1:1"));
306        assert!(formatted.contains("error"));
307        assert!(formatted.contains("E001"));
308        assert!(formatted.contains("command 'foo' not found"));
309        assert!(formatted.contains("did you mean 'for'?"));
310    }
311
312    #[test]
313    fn get_line_at_offset_works() {
314        let source = "line one\nline two\nline three";
315        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
316        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
317        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
318    }
319}