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}