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///
7/// `#[non_exhaustive]`: `KernelError` tells embedders to route on a
8/// rejection's issues, so a level added later must not break a matcher that
9/// already handles the ones it knows.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Severity {
13    /// Errors prevent execution.
14    Error,
15    /// Warnings are advisory but allow execution.
16    Warning,
17}
18
19impl fmt::Display for Severity {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Severity::Error => write!(f, "error"),
23            Severity::Warning => write!(f, "warning"),
24        }
25    }
26}
27
28/// Categorizes validation issues for filtering and tooling.
29///
30/// `#[non_exhaustive]`: `docs/EMBEDDING.md` tells embedders to route on this
31/// code rather than on message text, and this list grows every cycle. An
32/// exhaustive `match` here would break on each new check, which is the same
33/// shape as all five of 0.15.0's undeclared breaking changes. Add a
34/// wildcard arm that fails loudly, never a silent default.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum IssueCode {
38    /// Command not found in registry or user tools.
39    UndefinedCommand,
40    /// Required parameter not provided.
41    MissingRequiredArg,
42    /// Flag not defined in tool schema.
43    UnknownFlag,
44    /// Argument type doesn't match schema.
45    InvalidArgType,
46    /// seq increment is zero (infinite loop).
47    SeqZeroIncrement,
48    /// Regex pattern is invalid.
49    InvalidRegex,
50    /// break/continue outside of a loop.
51    BreakOutsideLoop,
52    /// return outside of a function.
53    ReturnOutsideFunction,
54    /// Variable may be undefined.
55    PossiblyUndefinedVariable,
56    /// Bare scalar variable in for loop (no word splitting in kaish).
57    ForLoopScalarVar,
58    /// scatter without gather — parallel results would be lost.
59    ScatterWithoutGather,
60    /// Field access on `$?` (e.g. `${?.data}`, `${?.ok}`) was removed.
61    /// `$?` is the POSIX exit code; use `kaish-last` for structured data.
62    LastResultFieldAccess,
63    /// diff was given other than two file operands.
64    DiffNeedsTwoFiles,
65    /// sed expression is syntactically invalid.
66    InvalidSedExpr,
67    /// jq filter expression is syntactically invalid.
68    InvalidJqFilter,
69    /// A subscripted assignment lvalue (`x[k]=v`) targets an undefined root
70    /// variable. Unlike a plain read, a path-set never autovivifies the
71    /// root — it must already exist as a collection.
72    LvalueUndefinedRoot,
73    /// An assignment target contains a dot (`user.email=x`). kaish is
74    /// brackets-only for collection access — the `Ident` token admits `.`
75    /// for other uses (filenames, `source foo.kai`), so this is caught here
76    /// rather than by tightening the lexer regex.
77    DottedAssignmentTarget,
78    /// An assignment target contains `#` (`abc#3=5`). The `Ident` token admits
79    /// `#` so words, ids, and URLs keep it, but `$abc#3` is itself an error,
80    /// so such a variable could be created and never read back. Caught here
81    /// rather than by tightening the lexer regex, for the same reason
82    /// `DottedAssignmentTarget` is.
83    UnreadableAssignmentTarget,
84    /// An assignment target holds a character that does not show itself —
85    /// whitespace, a zero-width character, or a bidi control. Most spellings
86    /// are caught earlier, on the token stream; this covers the ones only the
87    /// syntax tree can tell apart from data, such as the second assignment in
88    /// an env-scoped prefix (`x=1 BAD=2 cmd`), where a target and an argv
89    /// `key=value` word look identical one token back.
90    InvisibleAssignmentTarget,
91    /// A name is spelled in two scripts, so it reads as a name it does not
92    /// bind — `PАTH` with CYRILLIC CAPITAL LETTER A (U+0410) binds a second
93    /// variable and leaves `$PATH` alone. UAX #39's Highly Restrictive
94    /// profile is the rule, so `café`, `名前`, and `変数x` stay quiet. A
95    /// warning, never an error: the name binds either way, and the author is
96    /// the only one who knows which name they meant.
97    MixedScriptName,
98    /// `test` was given an XSI compound/grouping operator (`-a`, `-o`,
99    /// `(`, `)`), which kaish does not implement.
100    TestCompoundOperator,
101}
102
103impl IssueCode {
104    /// Returns a short code string for the issue.
105    ///
106    /// Code numbers are stable identifiers, not contiguous. E010 and
107    /// W003/W004/W005 remain retired, as does W006 (PosixTestCommand, retired
108    /// when `test` became a first-class builtin) — W007 is the next free
109    /// warning number, not a reuse of one of them. E020 covers the same
110    /// builtin as retired W006 but is a different judgement: W006 warned that
111    /// `[` was not kaish's, E020 rejects an operator `test` will refuse at
112    /// runtime anyway. E006 (InvalidSedExpr), E007
113    /// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
114    /// real emitters in 2026-06-14.
115    pub fn code(&self) -> &'static str {
116        match self {
117            IssueCode::UndefinedCommand => "E001",
118            IssueCode::MissingRequiredArg => "E002",
119            IssueCode::UnknownFlag => "W001",
120            IssueCode::InvalidArgType => "E003",
121            IssueCode::SeqZeroIncrement => "E004",
122            IssueCode::InvalidRegex => "E005",
123            IssueCode::InvalidSedExpr => "E006",
124            IssueCode::InvalidJqFilter => "E007",
125            IssueCode::BreakOutsideLoop => "E008",
126            IssueCode::ReturnOutsideFunction => "E009",
127            // E010 retired — never emitted
128            IssueCode::PossiblyUndefinedVariable => "W002",
129            IssueCode::DiffNeedsTwoFiles => "E011",
130            IssueCode::ForLoopScalarVar => "E012",
131            IssueCode::ScatterWithoutGather => "E014",
132            IssueCode::LastResultFieldAccess => "E015",
133            IssueCode::LvalueUndefinedRoot => "E016",
134            IssueCode::DottedAssignmentTarget => "E017",
135            IssueCode::UnreadableAssignmentTarget => "E018",
136            IssueCode::InvisibleAssignmentTarget => "E019",
137            IssueCode::MixedScriptName => "W007",
138            IssueCode::TestCompoundOperator => "E020",
139        }
140    }
141
142    /// Whether a warning carrying this code should be surfaced to the agent
143    /// (appended to the result's stderr) rather than only trace-logged.
144    ///
145    /// Most warnings stay trace-only — `UndefinedCommand` fires on every
146    /// external command (`grep`, `cargo`), so surfacing them all would be
147    /// noise. Opt a code in here only when its guidance is worth interrupting
148    /// for; this is the boundary between the two.
149    ///
150    /// `MixedScriptName` is opted in. It reports a name whose spelling and
151    /// binding disagree, which nothing else reports — the exit code is 0 and
152    /// the output looks right — so a trace-only warning would report it to
153    /// nobody. Add a code to the `matches!` arm when the same is true of it.
154    pub fn surfaces_to_agent(&self) -> bool {
155        matches!(self, IssueCode::MixedScriptName)
156    }
157
158    /// Default severity for this issue code.
159    pub fn default_severity(&self) -> Severity {
160        match self {
161            // These are hard errors that will definitely fail at runtime
162            IssueCode::SeqZeroIncrement
163            | IssueCode::InvalidRegex
164            | IssueCode::InvalidSedExpr
165            | IssueCode::InvalidJqFilter
166            | IssueCode::DiffNeedsTwoFiles
167            | IssueCode::BreakOutsideLoop
168            | IssueCode::ReturnOutsideFunction
169            | IssueCode::ForLoopScalarVar
170            | IssueCode::ScatterWithoutGather
171            | IssueCode::TestCompoundOperator
172            | IssueCode::LastResultFieldAccess
173            | IssueCode::LvalueUndefinedRoot
174            | IssueCode::DottedAssignmentTarget
175            | IssueCode::UnreadableAssignmentTarget
176            | IssueCode::InvisibleAssignmentTarget => Severity::Error,
177
178            // These are warnings because context matters:
179            // - MissingRequiredArg: might be provided by pipeline stdin or environment
180            // - InvalidArgType: shell coerces types at runtime
181            // - UndefinedCommand: might be script in PATH or external tool
182            IssueCode::MissingRequiredArg
183            | IssueCode::InvalidArgType
184            | IssueCode::UndefinedCommand
185            | IssueCode::UnknownFlag
186            | IssueCode::PossiblyUndefinedVariable
187            | IssueCode::MixedScriptName => Severity::Warning,
188        }
189    }
190}
191
192impl fmt::Display for IssueCode {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        write!(f, "{}", self.code())
195    }
196}
197
198/// Source location span.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub struct Span {
201    /// Start byte offset in source.
202    pub start: usize,
203    /// End byte offset in source.
204    pub end: usize,
205}
206
207impl Span {
208    /// Create a new span.
209    pub fn new(start: usize, end: usize) -> Self {
210        Self { start, end }
211    }
212
213    /// Convert byte offset to line:column.
214    ///
215    /// Returns (line, column) where both are 1-indexed.
216    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
217        let mut line = 1;
218        let mut col = 1;
219
220        for (i, ch) in source.char_indices() {
221            if i >= self.start {
222                break;
223            }
224            if ch == '\n' {
225                line += 1;
226                col = 1;
227            } else {
228                col += 1;
229            }
230        }
231
232        (line, col)
233    }
234
235    /// Format span as "line:col" string.
236    pub fn format_location(&self, source: &str) -> String {
237        let (line, col) = self.to_line_col(source);
238        format!("{}:{}", line, col)
239    }
240}
241
242/// A validation issue found in the script.
243#[derive(Debug, Clone)]
244#[non_exhaustive]
245pub struct ValidationIssue {
246    /// Severity level.
247    pub severity: Severity,
248    /// Issue category code.
249    pub code: IssueCode,
250    /// Human-readable message.
251    pub message: String,
252    /// Optional source location.
253    pub span: Option<Span>,
254    /// Optional suggestion for fixing the issue.
255    pub suggestion: Option<String>,
256}
257
258impl ValidationIssue {
259    /// Create a new validation error.
260    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
261        Self {
262            severity: Severity::Error,
263            code,
264            message: message.into(),
265            span: None,
266            suggestion: None,
267        }
268    }
269
270    /// Create a new validation warning.
271    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
272        Self {
273            severity: Severity::Warning,
274            code,
275            message: message.into(),
276            span: None,
277            suggestion: None,
278        }
279    }
280
281    /// Add a span to this issue.
282    pub fn with_span(mut self, span: Span) -> Self {
283        self.span = Some(span);
284        self
285    }
286
287    /// Add a suggestion to this issue.
288    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
289        self.suggestion = Some(suggestion.into());
290        self
291    }
292
293    /// Format the issue for display.
294    ///
295    /// With source provided, includes line:column information and source context.
296    pub fn format(&self, source: &str) -> String {
297        let mut result = String::new();
298
299        // Location prefix if we have a span
300        if let Some(span) = &self.span {
301            let loc = span.format_location(source);
302            result.push_str(&format!("{}: ", loc));
303        }
304
305        // Severity and code
306        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
307
308        // Suggestion if available
309        if let Some(suggestion) = &self.suggestion {
310            result.push_str(&format!("\n  → {}", suggestion));
311        }
312
313        // Source context if we have a span
314        if let Some(span) = &self.span
315            && let Some(line_content) = get_line_at_offset(source, span.start) {
316                result.push_str(&format!("\n  | {}", line_content));
317            }
318
319        result
320    }
321}
322
323impl fmt::Display for ValidationIssue {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
326    }
327}
328
329/// Get the line containing a byte offset.
330fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
331    if offset >= source.len() {
332        return None;
333    }
334
335    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
336    let end = source[offset..]
337        .find('\n')
338        .map_or(source.len(), |i| offset + i);
339
340    Some(&source[start..end])
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn span_to_line_col_single_line() {
349        let source = "echo hello world";
350        let span = Span::new(5, 10);
351        assert_eq!(span.to_line_col(source), (1, 6));
352    }
353
354    #[test]
355    fn span_to_line_col_multi_line() {
356        let source = "line one\nline two\nline three";
357        // "line" on line 3 starts at offset 18
358        let span = Span::new(18, 22);
359        assert_eq!(span.to_line_col(source), (3, 1));
360    }
361
362    #[test]
363    fn span_format_location() {
364        let source = "first\nsecond\nthird";
365        let span = Span::new(6, 12); // "second"
366        assert_eq!(span.format_location(source), "2:1");
367    }
368
369    #[test]
370    fn issue_formatting() {
371        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
372            .with_span(Span::new(0, 3))
373            .with_suggestion("did you mean 'for'?");
374
375        let source = "foo bar";
376        let formatted = issue.format(source);
377
378        assert!(formatted.contains("1:1"));
379        assert!(formatted.contains("error"));
380        assert!(formatted.contains("E001"));
381        assert!(formatted.contains("command 'foo' not found"));
382        assert!(formatted.contains("did you mean 'for'?"));
383    }
384
385    #[test]
386    fn get_line_at_offset_works() {
387        let source = "line one\nline two\nline three";
388        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
389        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
390        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
391    }
392}