Skip to main content

kaish_tool_api/
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    /// break/continue outside of a loop.
39    BreakOutsideLoop,
40    /// return outside of a function.
41    ReturnOutsideFunction,
42    /// Variable may be undefined.
43    PossiblyUndefinedVariable,
44    /// Bare scalar variable in for loop (no word splitting in kaish).
45    ForLoopScalarVar,
46    /// scatter without gather — parallel results would be lost.
47    ScatterWithoutGather,
48    /// Field access on `$?` (e.g. `${?.data}`, `${?.ok}`) was removed.
49    /// `$?` is the POSIX exit code; use `kaish-last` for structured data.
50    LastResultFieldAccess,
51    /// diff was given other than two file operands.
52    DiffNeedsTwoFiles,
53    /// sed expression is syntactically invalid.
54    InvalidSedExpr,
55    /// jq filter expression is syntactically invalid.
56    InvalidJqFilter,
57    /// A subscripted assignment lvalue (`x[k]=v`) targets an undefined root
58    /// variable. Unlike a plain read, a path-set never autovivifies the
59    /// root — it must already exist as a collection.
60    LvalueUndefinedRoot,
61    /// An assignment target contains a dot (`user.email=x`). kaish is
62    /// brackets-only for collection access — the `Ident` token admits `.`
63    /// for other uses (filenames, `source foo.kai`), so this is caught here
64    /// rather than by tightening the lexer regex.
65    DottedAssignmentTarget,
66}
67
68impl IssueCode {
69    /// Returns a short code string for the issue.
70    ///
71    /// Code numbers are stable identifiers, not contiguous. E010 and
72    /// W003/W004/W005 remain retired, as does W006 (PosixTestCommand, retired
73    /// when `test` became a first-class builtin). E006 (InvalidSedExpr), E007
74    /// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
75    /// real emitters in 2026-06-14.
76    pub fn code(&self) -> &'static str {
77        match self {
78            IssueCode::UndefinedCommand => "E001",
79            IssueCode::MissingRequiredArg => "E002",
80            IssueCode::UnknownFlag => "W001",
81            IssueCode::InvalidArgType => "E003",
82            IssueCode::SeqZeroIncrement => "E004",
83            IssueCode::InvalidRegex => "E005",
84            IssueCode::InvalidSedExpr => "E006",
85            IssueCode::InvalidJqFilter => "E007",
86            IssueCode::BreakOutsideLoop => "E008",
87            IssueCode::ReturnOutsideFunction => "E009",
88            // E010 retired — never emitted
89            IssueCode::PossiblyUndefinedVariable => "W002",
90            IssueCode::DiffNeedsTwoFiles => "E011",
91            IssueCode::ForLoopScalarVar => "E012",
92            IssueCode::ScatterWithoutGather => "E014",
93            IssueCode::LastResultFieldAccess => "E015",
94            IssueCode::LvalueUndefinedRoot => "E016",
95            IssueCode::DottedAssignmentTarget => "E017",
96        }
97    }
98
99    /// Whether a warning carrying this code should be surfaced to the agent
100    /// (appended to the result's stderr) rather than only trace-logged.
101    ///
102    /// Most warnings stay trace-only — `UndefinedCommand` fires on every
103    /// external command (`grep`, `cargo`), so surfacing them all would be
104    /// noise. Opt a code in here only when its guidance is worth interrupting
105    /// for. This is the surfacing seam for the "did-you-mean" guidance pass.
106    ///
107    /// Currently dormant: the one opted-in code (`PosixTestCommand`) was retired
108    /// when `test` became a builtin. The seam stays wired for the next code that
109    /// earns surfacing — add a `matches!(self, IssueCode::Foo | …)` arm here.
110    pub fn surfaces_to_agent(&self) -> bool {
111        let _ = self;
112        false
113    }
114
115    /// Default severity for this issue code.
116    pub fn default_severity(&self) -> Severity {
117        match self {
118            // These are hard errors that will definitely fail at runtime
119            IssueCode::SeqZeroIncrement
120            | IssueCode::InvalidRegex
121            | IssueCode::InvalidSedExpr
122            | IssueCode::InvalidJqFilter
123            | IssueCode::DiffNeedsTwoFiles
124            | IssueCode::BreakOutsideLoop
125            | IssueCode::ReturnOutsideFunction
126            | IssueCode::ForLoopScalarVar
127            | IssueCode::ScatterWithoutGather
128            | IssueCode::LastResultFieldAccess
129            | IssueCode::LvalueUndefinedRoot
130            | IssueCode::DottedAssignmentTarget => Severity::Error,
131
132            // These are warnings because context matters:
133            // - MissingRequiredArg: might be provided by pipeline stdin or environment
134            // - InvalidArgType: shell coerces types at runtime
135            // - UndefinedCommand: might be script in PATH or external tool
136            IssueCode::MissingRequiredArg
137            | IssueCode::InvalidArgType
138            | IssueCode::UndefinedCommand
139            | IssueCode::UnknownFlag
140            | IssueCode::PossiblyUndefinedVariable => Severity::Warning,
141        }
142    }
143}
144
145impl fmt::Display for IssueCode {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(f, "{}", self.code())
148    }
149}
150
151/// Source location span.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub struct Span {
154    /// Start byte offset in source.
155    pub start: usize,
156    /// End byte offset in source.
157    pub end: usize,
158}
159
160impl Span {
161    /// Create a new span.
162    pub fn new(start: usize, end: usize) -> Self {
163        Self { start, end }
164    }
165
166    /// Convert byte offset to line:column.
167    ///
168    /// Returns (line, column) where both are 1-indexed.
169    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
170        let mut line = 1;
171        let mut col = 1;
172
173        for (i, ch) in source.char_indices() {
174            if i >= self.start {
175                break;
176            }
177            if ch == '\n' {
178                line += 1;
179                col = 1;
180            } else {
181                col += 1;
182            }
183        }
184
185        (line, col)
186    }
187
188    /// Format span as "line:col" string.
189    pub fn format_location(&self, source: &str) -> String {
190        let (line, col) = self.to_line_col(source);
191        format!("{}:{}", line, col)
192    }
193}
194
195/// A validation issue found in the script.
196#[derive(Debug, Clone)]
197#[non_exhaustive]
198pub struct ValidationIssue {
199    /// Severity level.
200    pub severity: Severity,
201    /// Issue category code.
202    pub code: IssueCode,
203    /// Human-readable message.
204    pub message: String,
205    /// Optional source location.
206    pub span: Option<Span>,
207    /// Optional suggestion for fixing the issue.
208    pub suggestion: Option<String>,
209}
210
211impl ValidationIssue {
212    /// Create a new validation error.
213    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
214        Self {
215            severity: Severity::Error,
216            code,
217            message: message.into(),
218            span: None,
219            suggestion: None,
220        }
221    }
222
223    /// Create a new validation warning.
224    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
225        Self {
226            severity: Severity::Warning,
227            code,
228            message: message.into(),
229            span: None,
230            suggestion: None,
231        }
232    }
233
234    /// Add a span to this issue.
235    pub fn with_span(mut self, span: Span) -> Self {
236        self.span = Some(span);
237        self
238    }
239
240    /// Add a suggestion to this issue.
241    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
242        self.suggestion = Some(suggestion.into());
243        self
244    }
245
246    /// Format the issue for display.
247    ///
248    /// With source provided, includes line:column information and source context.
249    pub fn format(&self, source: &str) -> String {
250        let mut result = String::new();
251
252        // Location prefix if we have a span
253        if let Some(span) = &self.span {
254            let loc = span.format_location(source);
255            result.push_str(&format!("{}: ", loc));
256        }
257
258        // Severity and code
259        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
260
261        // Suggestion if available
262        if let Some(suggestion) = &self.suggestion {
263            result.push_str(&format!("\n  → {}", suggestion));
264        }
265
266        // Source context if we have a span
267        if let Some(span) = &self.span
268            && let Some(line_content) = get_line_at_offset(source, span.start) {
269                result.push_str(&format!("\n  | {}", line_content));
270            }
271
272        result
273    }
274}
275
276impl fmt::Display for ValidationIssue {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
279    }
280}
281
282/// Get the line containing a byte offset.
283fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
284    if offset >= source.len() {
285        return None;
286    }
287
288    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
289    let end = source[offset..]
290        .find('\n')
291        .map_or(source.len(), |i| offset + i);
292
293    Some(&source[start..end])
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn span_to_line_col_single_line() {
302        let source = "echo hello world";
303        let span = Span::new(5, 10);
304        assert_eq!(span.to_line_col(source), (1, 6));
305    }
306
307    #[test]
308    fn span_to_line_col_multi_line() {
309        let source = "line one\nline two\nline three";
310        // "line" on line 3 starts at offset 18
311        let span = Span::new(18, 22);
312        assert_eq!(span.to_line_col(source), (3, 1));
313    }
314
315    #[test]
316    fn span_format_location() {
317        let source = "first\nsecond\nthird";
318        let span = Span::new(6, 12); // "second"
319        assert_eq!(span.format_location(source), "2:1");
320    }
321
322    #[test]
323    fn issue_formatting() {
324        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
325            .with_span(Span::new(0, 3))
326            .with_suggestion("did you mean 'for'?");
327
328        let source = "foo bar";
329        let formatted = issue.format(source);
330
331        assert!(formatted.contains("1:1"));
332        assert!(formatted.contains("error"));
333        assert!(formatted.contains("E001"));
334        assert!(formatted.contains("command 'foo' not found"));
335        assert!(formatted.contains("did you mean 'for'?"));
336    }
337
338    #[test]
339    fn get_line_at_offset_works() {
340        let source = "line one\nline two\nline three";
341        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
342        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
343        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
344    }
345}