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}