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