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    /// A wrapped command's declaration refuses the call: the verb, the
102    /// argument shape, or a constrained path is not one it describes. The
103    /// message names the offending word and the allowed set. Covers the
104    /// refusals no other code fits — an unknown flag, a missing required
105    /// argument, and a bad value keep `UnknownFlag`, `MissingRequiredArg`,
106    /// and `InvalidArgType`.
107    WrappedCallRejected,
108}
109
110impl IssueCode {
111    /// Returns a short code string for the issue.
112    ///
113    /// Code numbers are stable identifiers, not contiguous. E010 and
114    /// W003/W004/W005 remain retired, as does W006 (PosixTestCommand, retired
115    /// when `test` became a first-class builtin) — W007 is the next free
116    /// warning number, not a reuse of one of them. E020 covers the same
117    /// builtin as retired W006 but is a different judgement: W006 warned that
118    /// `[` was not kaish's, E020 rejects an operator `test` will refuse at
119    /// runtime anyway. E006 (InvalidSedExpr), E007
120    /// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
121    /// real emitters in 2026-06-14.
122    pub fn code(&self) -> &'static str {
123        match self {
124            IssueCode::UndefinedCommand => "E001",
125            IssueCode::MissingRequiredArg => "E002",
126            IssueCode::UnknownFlag => "W001",
127            IssueCode::InvalidArgType => "E003",
128            IssueCode::SeqZeroIncrement => "E004",
129            IssueCode::InvalidRegex => "E005",
130            IssueCode::InvalidSedExpr => "E006",
131            IssueCode::InvalidJqFilter => "E007",
132            IssueCode::BreakOutsideLoop => "E008",
133            IssueCode::ReturnOutsideFunction => "E009",
134            // E010 retired — never emitted
135            IssueCode::PossiblyUndefinedVariable => "W002",
136            IssueCode::DiffNeedsTwoFiles => "E011",
137            IssueCode::ForLoopScalarVar => "E012",
138            IssueCode::ScatterWithoutGather => "E014",
139            IssueCode::LastResultFieldAccess => "E015",
140            IssueCode::LvalueUndefinedRoot => "E016",
141            IssueCode::DottedAssignmentTarget => "E017",
142            IssueCode::UnreadableAssignmentTarget => "E018",
143            IssueCode::InvisibleAssignmentTarget => "E019",
144            IssueCode::MixedScriptName => "W007",
145            IssueCode::TestCompoundOperator => "E020",
146            IssueCode::WrappedCallRejected => "E021",
147        }
148    }
149
150    /// Whether a warning carrying this code should be surfaced to the agent
151    /// (appended to the result's stderr) rather than only trace-logged.
152    ///
153    /// Most warnings stay trace-only — `UndefinedCommand` fires on every
154    /// external command (`grep`, `cargo`), so surfacing them all would be
155    /// noise. Opt a code in here only when its guidance is worth interrupting
156    /// for; this is the boundary between the two.
157    ///
158    /// `MixedScriptName` is opted in. It reports a name whose spelling and
159    /// binding disagree, which nothing else reports — the exit code is 0 and
160    /// the output looks right — so a trace-only warning would report it to
161    /// nobody. Add a code to the `matches!` arm when the same is true of it.
162    pub fn surfaces_to_agent(&self) -> bool {
163        matches!(self, IssueCode::MixedScriptName)
164    }
165
166    /// Default severity for this issue code.
167    pub fn default_severity(&self) -> Severity {
168        match self {
169            // These are hard errors that will definitely fail at runtime
170            IssueCode::SeqZeroIncrement
171            | IssueCode::InvalidRegex
172            | IssueCode::InvalidSedExpr
173            | IssueCode::InvalidJqFilter
174            | IssueCode::DiffNeedsTwoFiles
175            | IssueCode::BreakOutsideLoop
176            | IssueCode::ReturnOutsideFunction
177            | IssueCode::ForLoopScalarVar
178            | IssueCode::ScatterWithoutGather
179            | IssueCode::TestCompoundOperator
180            | IssueCode::WrappedCallRejected
181            | IssueCode::LastResultFieldAccess
182            | IssueCode::LvalueUndefinedRoot
183            | IssueCode::DottedAssignmentTarget
184            | IssueCode::UnreadableAssignmentTarget
185            | IssueCode::InvisibleAssignmentTarget => Severity::Error,
186
187            // These are warnings because context matters:
188            // - MissingRequiredArg: might be provided by pipeline stdin or environment
189            // - InvalidArgType: shell coerces types at runtime
190            // - UndefinedCommand: might be script in PATH or external tool
191            IssueCode::MissingRequiredArg
192            | IssueCode::InvalidArgType
193            | IssueCode::UndefinedCommand
194            | IssueCode::UnknownFlag
195            | IssueCode::PossiblyUndefinedVariable
196            | IssueCode::MixedScriptName => Severity::Warning,
197        }
198    }
199}
200
201impl fmt::Display for IssueCode {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.code())
204    }
205}
206
207/// Source location span.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
209pub struct Span {
210    /// Start byte offset in source.
211    pub start: usize,
212    /// End byte offset in source.
213    pub end: usize,
214}
215
216impl Span {
217    /// Create a new span.
218    pub fn new(start: usize, end: usize) -> Self {
219        Self { start, end }
220    }
221
222    /// Convert byte offset to line:column.
223    ///
224    /// Returns (line, column) where both are 1-indexed.
225    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
226        let mut line = 1;
227        let mut col = 1;
228
229        for (i, ch) in source.char_indices() {
230            if i >= self.start {
231                break;
232            }
233            if ch == '\n' {
234                line += 1;
235                col = 1;
236            } else {
237                col += 1;
238            }
239        }
240
241        (line, col)
242    }
243
244    /// Format span as "line:col" string.
245    pub fn format_location(&self, source: &str) -> String {
246        let (line, col) = self.to_line_col(source);
247        format!("{}:{}", line, col)
248    }
249}
250
251/// A validation issue found in the script.
252#[derive(Debug, Clone)]
253#[non_exhaustive]
254pub struct ValidationIssue {
255    /// Severity level.
256    pub severity: Severity,
257    /// Issue category code.
258    pub code: IssueCode,
259    /// Human-readable message.
260    pub message: String,
261    /// Optional source location.
262    pub span: Option<Span>,
263    /// Optional suggestion for fixing the issue.
264    pub suggestion: Option<String>,
265    /// The command this issue concerns, when one is genuinely known.
266    ///
267    /// `Some(name)` when a name is on hand: a builtin's own `Tool::validate`
268    /// raising about itself, a schema-driven argument issue (the schema's
269    /// name), a wrapped command's refusal, `UndefinedCommand`'s unresolved
270    /// name, `scatter` without a gather, and a user tool's arity failure.
271    ///
272    /// `None`, never a placeholder, when the issue is not about a command at
273    /// all — an assignment target, a bare `break`, an undefined variable, or
274    /// `MixedScriptName`, where the mis-spelled name is the argument.
275    ///
276    /// Severity varies: `UndefinedCommand` is a Warning and so never reaches
277    /// `KernelError::Validation`, which kaish-kernel filters to Error.
278    /// Reading it means driving the `Validator` directly.
279    ///
280    /// One limit: schema-driven issues record the SCHEMA's name, which equals
281    /// the invoked name for every builtin (pinned by a registry test) but is
282    /// not enforced for a tool an embedder registers.
283    ///
284    /// Route on `code`, then narrow by `command`; don't parse `message` to
285    /// recover a name this field already gives you.
286    pub command: Option<String>,
287}
288
289impl ValidationIssue {
290    /// Create a new validation error.
291    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
292        Self {
293            severity: Severity::Error,
294            code,
295            message: message.into(),
296            span: None,
297            suggestion: None,
298            command: None,
299        }
300    }
301
302    /// Create a new validation warning.
303    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
304        Self {
305            severity: Severity::Warning,
306            code,
307            message: message.into(),
308            span: None,
309            suggestion: None,
310            command: None,
311        }
312    }
313
314    /// Add a span to this issue.
315    pub fn with_span(mut self, span: Span) -> Self {
316        self.span = Some(span);
317        self
318    }
319
320    /// Add a suggestion to this issue.
321    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
322        self.suggestion = Some(suggestion.into());
323        self
324    }
325
326    /// Record the command this issue concerns.
327    ///
328    /// Call this only where the name is genuinely known at the construction
329    /// site — the tool being validated, or the unresolved name itself for
330    /// `UndefinedCommand`. Leave it unset rather than guess.
331    pub fn with_command(mut self, command: impl Into<String>) -> Self {
332        self.command = Some(command.into());
333        self
334    }
335
336    /// Format the issue for display.
337    ///
338    /// With source provided, includes line:column information and source context.
339    pub fn format(&self, source: &str) -> String {
340        let mut result = String::new();
341
342        // Location prefix if we have a span
343        if let Some(span) = &self.span {
344            let loc = span.format_location(source);
345            result.push_str(&format!("{}: ", loc));
346        }
347
348        // Severity and code
349        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
350
351        // Suggestion if available
352        if let Some(suggestion) = &self.suggestion {
353            result.push_str(&format!("\n  → {}", suggestion));
354        }
355
356        // Source context if we have a span
357        if let Some(span) = &self.span
358            && let Some(line_content) = get_line_at_offset(source, span.start) {
359                result.push_str(&format!("\n  | {}", line_content));
360            }
361
362        result
363    }
364}
365
366impl fmt::Display for ValidationIssue {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
369    }
370}
371
372/// Get the line containing a byte offset.
373fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
374    if offset >= source.len() {
375        return None;
376    }
377
378    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
379    let end = source[offset..]
380        .find('\n')
381        .map_or(source.len(), |i| offset + i);
382
383    Some(&source[start..end])
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn span_to_line_col_single_line() {
392        let source = "echo hello world";
393        let span = Span::new(5, 10);
394        assert_eq!(span.to_line_col(source), (1, 6));
395    }
396
397    #[test]
398    fn span_to_line_col_multi_line() {
399        let source = "line one\nline two\nline three";
400        // "line" on line 3 starts at offset 18
401        let span = Span::new(18, 22);
402        assert_eq!(span.to_line_col(source), (3, 1));
403    }
404
405    #[test]
406    fn span_format_location() {
407        let source = "first\nsecond\nthird";
408        let span = Span::new(6, 12); // "second"
409        assert_eq!(span.format_location(source), "2:1");
410    }
411
412    #[test]
413    fn issue_formatting() {
414        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
415            .with_span(Span::new(0, 3))
416            .with_suggestion("did you mean 'for'?");
417
418        let source = "foo bar";
419        let formatted = issue.format(source);
420
421        assert!(formatted.contains("1:1"));
422        assert!(formatted.contains("error"));
423        assert!(formatted.contains("E001"));
424        assert!(formatted.contains("command 'foo' not found"));
425        assert!(formatted.contains("did you mean 'for'?"));
426    }
427
428    #[test]
429    fn command_absent_by_default() {
430        let error = ValidationIssue::error(IssueCode::BreakOutsideLoop, "break outside a loop");
431        assert_eq!(error.command, None);
432
433        let warning = ValidationIssue::warning(IssueCode::PossiblyUndefinedVariable, "maybe undefined");
434        assert_eq!(warning.command, None);
435    }
436
437    #[test]
438    fn with_command_records_the_name() {
439        let issue = ValidationIssue::error(IssueCode::SeqZeroIncrement, "seq: increment cannot be zero")
440            .with_command("seq");
441        assert_eq!(issue.command.as_deref(), Some("seq"));
442    }
443
444    #[test]
445    fn get_line_at_offset_works() {
446        let source = "line one\nline two\nline three";
447        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
448        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
449        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
450    }
451}