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    /// An assignment target contains `#` (`abc#3=5`). The `Ident` token admits
67    /// `#` so words, ids, and URLs keep it, but `$abc#3` is itself an error,
68    /// so such a variable could be created and never read back. Caught here
69    /// rather than by tightening the lexer regex, for the same reason
70    /// `DottedAssignmentTarget` is.
71    UnreadableAssignmentTarget,
72    /// An assignment target holds a character that does not show itself —
73    /// whitespace, a zero-width character, or a bidi control. Most spellings
74    /// are caught earlier, on the token stream; this covers the ones only the
75    /// syntax tree can tell apart from data, such as the second assignment in
76    /// an env-scoped prefix (`x=1 BAD=2 cmd`), where a target and an argv
77    /// `key=value` word look identical one token back.
78    InvisibleAssignmentTarget,
79    /// A name is spelled in two scripts, so it reads as a name it does not
80    /// bind — `PАTH` with CYRILLIC CAPITAL LETTER A (U+0410) binds a second
81    /// variable and leaves `$PATH` alone. UAX #39's Highly Restrictive
82    /// profile is the rule, so `café`, `名前`, and `変数x` stay quiet. A
83    /// warning, never an error: the name binds either way, and the author is
84    /// the only one who knows which name they meant.
85    MixedScriptName,
86}
87
88impl IssueCode {
89    /// Returns a short code string for the issue.
90    ///
91    /// Code numbers are stable identifiers, not contiguous. E010 and
92    /// W003/W004/W005 remain retired, as does W006 (PosixTestCommand, retired
93    /// when `test` became a first-class builtin) — W007 is the next free
94    /// warning number, not a reuse of one of them. E006 (InvalidSedExpr), E007
95    /// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
96    /// real emitters in 2026-06-14.
97    pub fn code(&self) -> &'static str {
98        match self {
99            IssueCode::UndefinedCommand => "E001",
100            IssueCode::MissingRequiredArg => "E002",
101            IssueCode::UnknownFlag => "W001",
102            IssueCode::InvalidArgType => "E003",
103            IssueCode::SeqZeroIncrement => "E004",
104            IssueCode::InvalidRegex => "E005",
105            IssueCode::InvalidSedExpr => "E006",
106            IssueCode::InvalidJqFilter => "E007",
107            IssueCode::BreakOutsideLoop => "E008",
108            IssueCode::ReturnOutsideFunction => "E009",
109            // E010 retired — never emitted
110            IssueCode::PossiblyUndefinedVariable => "W002",
111            IssueCode::DiffNeedsTwoFiles => "E011",
112            IssueCode::ForLoopScalarVar => "E012",
113            IssueCode::ScatterWithoutGather => "E014",
114            IssueCode::LastResultFieldAccess => "E015",
115            IssueCode::LvalueUndefinedRoot => "E016",
116            IssueCode::DottedAssignmentTarget => "E017",
117            IssueCode::UnreadableAssignmentTarget => "E018",
118            IssueCode::InvisibleAssignmentTarget => "E019",
119            IssueCode::MixedScriptName => "W007",
120        }
121    }
122
123    /// Whether a warning carrying this code should be surfaced to the agent
124    /// (appended to the result's stderr) rather than only trace-logged.
125    ///
126    /// Most warnings stay trace-only — `UndefinedCommand` fires on every
127    /// external command (`grep`, `cargo`), so surfacing them all would be
128    /// noise. Opt a code in here only when its guidance is worth interrupting
129    /// for; this is the boundary between the two.
130    ///
131    /// `MixedScriptName` is opted in. It reports a name whose spelling and
132    /// binding disagree, which nothing else reports — the exit code is 0 and
133    /// the output looks right — so a trace-only warning would report it to
134    /// nobody. Add a code to the `matches!` arm when the same is true of it.
135    pub fn surfaces_to_agent(&self) -> bool {
136        matches!(self, IssueCode::MixedScriptName)
137    }
138
139    /// Default severity for this issue code.
140    pub fn default_severity(&self) -> Severity {
141        match self {
142            // These are hard errors that will definitely fail at runtime
143            IssueCode::SeqZeroIncrement
144            | IssueCode::InvalidRegex
145            | IssueCode::InvalidSedExpr
146            | IssueCode::InvalidJqFilter
147            | IssueCode::DiffNeedsTwoFiles
148            | IssueCode::BreakOutsideLoop
149            | IssueCode::ReturnOutsideFunction
150            | IssueCode::ForLoopScalarVar
151            | IssueCode::ScatterWithoutGather
152            | IssueCode::LastResultFieldAccess
153            | IssueCode::LvalueUndefinedRoot
154            | IssueCode::DottedAssignmentTarget
155            | IssueCode::UnreadableAssignmentTarget
156            | IssueCode::InvisibleAssignmentTarget => Severity::Error,
157
158            // These are warnings because context matters:
159            // - MissingRequiredArg: might be provided by pipeline stdin or environment
160            // - InvalidArgType: shell coerces types at runtime
161            // - UndefinedCommand: might be script in PATH or external tool
162            IssueCode::MissingRequiredArg
163            | IssueCode::InvalidArgType
164            | IssueCode::UndefinedCommand
165            | IssueCode::UnknownFlag
166            | IssueCode::PossiblyUndefinedVariable
167            | IssueCode::MixedScriptName => Severity::Warning,
168        }
169    }
170}
171
172impl fmt::Display for IssueCode {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "{}", self.code())
175    }
176}
177
178/// Source location span.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub struct Span {
181    /// Start byte offset in source.
182    pub start: usize,
183    /// End byte offset in source.
184    pub end: usize,
185}
186
187impl Span {
188    /// Create a new span.
189    pub fn new(start: usize, end: usize) -> Self {
190        Self { start, end }
191    }
192
193    /// Convert byte offset to line:column.
194    ///
195    /// Returns (line, column) where both are 1-indexed.
196    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
197        let mut line = 1;
198        let mut col = 1;
199
200        for (i, ch) in source.char_indices() {
201            if i >= self.start {
202                break;
203            }
204            if ch == '\n' {
205                line += 1;
206                col = 1;
207            } else {
208                col += 1;
209            }
210        }
211
212        (line, col)
213    }
214
215    /// Format span as "line:col" string.
216    pub fn format_location(&self, source: &str) -> String {
217        let (line, col) = self.to_line_col(source);
218        format!("{}:{}", line, col)
219    }
220}
221
222/// A validation issue found in the script.
223#[derive(Debug, Clone)]
224#[non_exhaustive]
225pub struct ValidationIssue {
226    /// Severity level.
227    pub severity: Severity,
228    /// Issue category code.
229    pub code: IssueCode,
230    /// Human-readable message.
231    pub message: String,
232    /// Optional source location.
233    pub span: Option<Span>,
234    /// Optional suggestion for fixing the issue.
235    pub suggestion: Option<String>,
236}
237
238impl ValidationIssue {
239    /// Create a new validation error.
240    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
241        Self {
242            severity: Severity::Error,
243            code,
244            message: message.into(),
245            span: None,
246            suggestion: None,
247        }
248    }
249
250    /// Create a new validation warning.
251    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
252        Self {
253            severity: Severity::Warning,
254            code,
255            message: message.into(),
256            span: None,
257            suggestion: None,
258        }
259    }
260
261    /// Add a span to this issue.
262    pub fn with_span(mut self, span: Span) -> Self {
263        self.span = Some(span);
264        self
265    }
266
267    /// Add a suggestion to this issue.
268    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
269        self.suggestion = Some(suggestion.into());
270        self
271    }
272
273    /// Format the issue for display.
274    ///
275    /// With source provided, includes line:column information and source context.
276    pub fn format(&self, source: &str) -> String {
277        let mut result = String::new();
278
279        // Location prefix if we have a span
280        if let Some(span) = &self.span {
281            let loc = span.format_location(source);
282            result.push_str(&format!("{}: ", loc));
283        }
284
285        // Severity and code
286        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
287
288        // Suggestion if available
289        if let Some(suggestion) = &self.suggestion {
290            result.push_str(&format!("\n  → {}", suggestion));
291        }
292
293        // Source context if we have a span
294        if let Some(span) = &self.span
295            && let Some(line_content) = get_line_at_offset(source, span.start) {
296                result.push_str(&format!("\n  | {}", line_content));
297            }
298
299        result
300    }
301}
302
303impl fmt::Display for ValidationIssue {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
306    }
307}
308
309/// Get the line containing a byte offset.
310fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
311    if offset >= source.len() {
312        return None;
313    }
314
315    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
316    let end = source[offset..]
317        .find('\n')
318        .map_or(source.len(), |i| offset + i);
319
320    Some(&source[start..end])
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn span_to_line_col_single_line() {
329        let source = "echo hello world";
330        let span = Span::new(5, 10);
331        assert_eq!(span.to_line_col(source), (1, 6));
332    }
333
334    #[test]
335    fn span_to_line_col_multi_line() {
336        let source = "line one\nline two\nline three";
337        // "line" on line 3 starts at offset 18
338        let span = Span::new(18, 22);
339        assert_eq!(span.to_line_col(source), (3, 1));
340    }
341
342    #[test]
343    fn span_format_location() {
344        let source = "first\nsecond\nthird";
345        let span = Span::new(6, 12); // "second"
346        assert_eq!(span.format_location(source), "2:1");
347    }
348
349    #[test]
350    fn issue_formatting() {
351        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
352            .with_span(Span::new(0, 3))
353            .with_suggestion("did you mean 'for'?");
354
355        let source = "foo bar";
356        let formatted = issue.format(source);
357
358        assert!(formatted.contains("1:1"));
359        assert!(formatted.contains("error"));
360        assert!(formatted.contains("E001"));
361        assert!(formatted.contains("command 'foo' not found"));
362        assert!(formatted.contains("did you mean 'for'?"));
363    }
364
365    #[test]
366    fn get_line_at_offset_works() {
367        let source = "line one\nline two\nline three";
368        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
369        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
370        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
371    }
372}