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    /// scatter without gather — parallel results would be lost.
63    ScatterWithoutGather,
64}
65
66impl IssueCode {
67    /// Returns a short code string for the issue.
68    pub fn code(&self) -> &'static str {
69        match self {
70            IssueCode::UndefinedCommand => "E001",
71            IssueCode::MissingRequiredArg => "E002",
72            IssueCode::UnknownFlag => "W001",
73            IssueCode::InvalidArgType => "E003",
74            IssueCode::SeqZeroIncrement => "E004",
75            IssueCode::InvalidRegex => "E005",
76            IssueCode::InvalidSedExpr => "E006",
77            IssueCode::InvalidJqFilter => "E007",
78            IssueCode::BreakOutsideLoop => "E008",
79            IssueCode::ReturnOutsideFunction => "E009",
80            IssueCode::PossiblyUndefinedVariable => "W002",
81            IssueCode::ConflictingFlags => "W003",
82            IssueCode::InvalidCount => "E010",
83            IssueCode::DiffNeedsTwoFiles => "E011",
84            IssueCode::RecursiveWithoutFlag => "W004",
85            IssueCode::ExtraPositionalArgs => "W005",
86            IssueCode::ForLoopScalarVar => "E012",
87            IssueCode::ShellGlobPattern => "E013",
88            IssueCode::ScatterWithoutGather => "E014",
89        }
90    }
91
92    /// Default severity for this issue code.
93    pub fn default_severity(&self) -> Severity {
94        match self {
95            // These are hard errors that will definitely fail at runtime
96            IssueCode::SeqZeroIncrement
97            | IssueCode::InvalidRegex
98            | IssueCode::InvalidSedExpr
99            | IssueCode::InvalidJqFilter
100            | IssueCode::BreakOutsideLoop
101            | IssueCode::ReturnOutsideFunction
102            | IssueCode::InvalidCount
103            | IssueCode::DiffNeedsTwoFiles
104            | IssueCode::ForLoopScalarVar
105            | IssueCode::ShellGlobPattern
106            | IssueCode::ScatterWithoutGather => Severity::Error,
107
108            // These are warnings because context matters:
109            // - MissingRequiredArg: might be provided by pipeline stdin or environment
110            // - InvalidArgType: shell coerces types at runtime
111            // - UndefinedCommand: might be script in PATH or external tool
112            IssueCode::MissingRequiredArg
113            | IssueCode::InvalidArgType
114            | IssueCode::UndefinedCommand
115            | IssueCode::UnknownFlag
116            | IssueCode::PossiblyUndefinedVariable
117            | IssueCode::ConflictingFlags
118            | IssueCode::RecursiveWithoutFlag
119            | IssueCode::ExtraPositionalArgs => Severity::Warning,
120        }
121    }
122}
123
124impl fmt::Display for IssueCode {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        write!(f, "{}", self.code())
127    }
128}
129
130/// Source location span.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub struct Span {
133    /// Start byte offset in source.
134    pub start: usize,
135    /// End byte offset in source.
136    pub end: usize,
137}
138
139impl Span {
140    /// Create a new span.
141    pub fn new(start: usize, end: usize) -> Self {
142        Self { start, end }
143    }
144
145    /// Convert byte offset to line:column.
146    ///
147    /// Returns (line, column) where both are 1-indexed.
148    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
149        let mut line = 1;
150        let mut col = 1;
151
152        for (i, ch) in source.char_indices() {
153            if i >= self.start {
154                break;
155            }
156            if ch == '\n' {
157                line += 1;
158                col = 1;
159            } else {
160                col += 1;
161            }
162        }
163
164        (line, col)
165    }
166
167    /// Format span as "line:col" string.
168    pub fn format_location(&self, source: &str) -> String {
169        let (line, col) = self.to_line_col(source);
170        format!("{}:{}", line, col)
171    }
172}
173
174/// A validation issue found in the script.
175#[derive(Debug, Clone)]
176pub struct ValidationIssue {
177    /// Severity level.
178    pub severity: Severity,
179    /// Issue category code.
180    pub code: IssueCode,
181    /// Human-readable message.
182    pub message: String,
183    /// Optional source location.
184    pub span: Option<Span>,
185    /// Optional suggestion for fixing the issue.
186    pub suggestion: Option<String>,
187}
188
189impl ValidationIssue {
190    /// Create a new validation error.
191    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
192        Self {
193            severity: Severity::Error,
194            code,
195            message: message.into(),
196            span: None,
197            suggestion: None,
198        }
199    }
200
201    /// Create a new validation warning.
202    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
203        Self {
204            severity: Severity::Warning,
205            code,
206            message: message.into(),
207            span: None,
208            suggestion: None,
209        }
210    }
211
212    /// Add a span to this issue.
213    pub fn with_span(mut self, span: Span) -> Self {
214        self.span = Some(span);
215        self
216    }
217
218    /// Add a suggestion to this issue.
219    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
220        self.suggestion = Some(suggestion.into());
221        self
222    }
223
224    /// Format the issue for display.
225    ///
226    /// With source provided, includes line:column information and source context.
227    pub fn format(&self, source: &str) -> String {
228        let mut result = String::new();
229
230        // Location prefix if we have a span
231        if let Some(span) = &self.span {
232            let loc = span.format_location(source);
233            result.push_str(&format!("{}: ", loc));
234        }
235
236        // Severity and code
237        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
238
239        // Suggestion if available
240        if let Some(suggestion) = &self.suggestion {
241            result.push_str(&format!("\n  → {}", suggestion));
242        }
243
244        // Source context if we have a span
245        if let Some(span) = &self.span
246            && let Some(line_content) = get_line_at_offset(source, span.start) {
247                result.push_str(&format!("\n  | {}", line_content));
248            }
249
250        result
251    }
252}
253
254impl fmt::Display for ValidationIssue {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
257    }
258}
259
260/// Get the line containing a byte offset.
261fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
262    if offset >= source.len() {
263        return None;
264    }
265
266    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
267    let end = source[offset..]
268        .find('\n')
269        .map_or(source.len(), |i| offset + i);
270
271    Some(&source[start..end])
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn span_to_line_col_single_line() {
280        let source = "echo hello world";
281        let span = Span::new(5, 10);
282        assert_eq!(span.to_line_col(source), (1, 6));
283    }
284
285    #[test]
286    fn span_to_line_col_multi_line() {
287        let source = "line one\nline two\nline three";
288        // "line" on line 3 starts at offset 18
289        let span = Span::new(18, 22);
290        assert_eq!(span.to_line_col(source), (3, 1));
291    }
292
293    #[test]
294    fn span_format_location() {
295        let source = "first\nsecond\nthird";
296        let span = Span::new(6, 12); // "second"
297        assert_eq!(span.format_location(source), "2:1");
298    }
299
300    #[test]
301    fn issue_formatting() {
302        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
303            .with_span(Span::new(0, 3))
304            .with_suggestion("did you mean 'for'?");
305
306        let source = "foo bar";
307        let formatted = issue.format(source);
308
309        assert!(formatted.contains("1:1"));
310        assert!(formatted.contains("error"));
311        assert!(formatted.contains("E001"));
312        assert!(formatted.contains("command 'foo' not found"));
313        assert!(formatted.contains("did you mean 'for'?"));
314    }
315
316    #[test]
317    fn get_line_at_offset_works() {
318        let source = "line one\nline two\nline three";
319        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
320        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
321        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
322    }
323}