1use std::fmt;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Severity {
8 Error,
10 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum IssueCode {
26 UndefinedCommand,
28 MissingRequiredArg,
30 UnknownFlag,
32 InvalidArgType,
34 SeqZeroIncrement,
36 InvalidRegex,
38 BreakOutsideLoop,
40 ReturnOutsideFunction,
42 PossiblyUndefinedVariable,
44 ForLoopScalarVar,
46 ScatterWithoutGather,
48 LastResultFieldAccess,
51 DiffNeedsTwoFiles,
53 InvalidSedExpr,
55 InvalidJqFilter,
57 LvalueUndefinedRoot,
61 DottedAssignmentTarget,
66}
67
68impl IssueCode {
69 pub fn code(&self) -> &'static str {
77 match self {
78 IssueCode::UndefinedCommand => "E001",
79 IssueCode::MissingRequiredArg => "E002",
80 IssueCode::UnknownFlag => "W001",
81 IssueCode::InvalidArgType => "E003",
82 IssueCode::SeqZeroIncrement => "E004",
83 IssueCode::InvalidRegex => "E005",
84 IssueCode::InvalidSedExpr => "E006",
85 IssueCode::InvalidJqFilter => "E007",
86 IssueCode::BreakOutsideLoop => "E008",
87 IssueCode::ReturnOutsideFunction => "E009",
88 IssueCode::PossiblyUndefinedVariable => "W002",
90 IssueCode::DiffNeedsTwoFiles => "E011",
91 IssueCode::ForLoopScalarVar => "E012",
92 IssueCode::ScatterWithoutGather => "E014",
93 IssueCode::LastResultFieldAccess => "E015",
94 IssueCode::LvalueUndefinedRoot => "E016",
95 IssueCode::DottedAssignmentTarget => "E017",
96 }
97 }
98
99 pub fn surfaces_to_agent(&self) -> bool {
111 let _ = self;
112 false
113 }
114
115 pub fn default_severity(&self) -> Severity {
117 match self {
118 IssueCode::SeqZeroIncrement
120 | IssueCode::InvalidRegex
121 | IssueCode::InvalidSedExpr
122 | IssueCode::InvalidJqFilter
123 | IssueCode::DiffNeedsTwoFiles
124 | IssueCode::BreakOutsideLoop
125 | IssueCode::ReturnOutsideFunction
126 | IssueCode::ForLoopScalarVar
127 | IssueCode::ScatterWithoutGather
128 | IssueCode::LastResultFieldAccess
129 | IssueCode::LvalueUndefinedRoot
130 | IssueCode::DottedAssignmentTarget => Severity::Error,
131
132 IssueCode::MissingRequiredArg
137 | IssueCode::InvalidArgType
138 | IssueCode::UndefinedCommand
139 | IssueCode::UnknownFlag
140 | IssueCode::PossiblyUndefinedVariable => Severity::Warning,
141 }
142 }
143}
144
145impl fmt::Display for IssueCode {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 write!(f, "{}", self.code())
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub struct Span {
154 pub start: usize,
156 pub end: usize,
158}
159
160impl Span {
161 pub fn new(start: usize, end: usize) -> Self {
163 Self { start, end }
164 }
165
166 pub fn to_line_col(&self, source: &str) -> (usize, usize) {
170 let mut line = 1;
171 let mut col = 1;
172
173 for (i, ch) in source.char_indices() {
174 if i >= self.start {
175 break;
176 }
177 if ch == '\n' {
178 line += 1;
179 col = 1;
180 } else {
181 col += 1;
182 }
183 }
184
185 (line, col)
186 }
187
188 pub fn format_location(&self, source: &str) -> String {
190 let (line, col) = self.to_line_col(source);
191 format!("{}:{}", line, col)
192 }
193}
194
195#[derive(Debug, Clone)]
197#[non_exhaustive]
198pub struct ValidationIssue {
199 pub severity: Severity,
201 pub code: IssueCode,
203 pub message: String,
205 pub span: Option<Span>,
207 pub suggestion: Option<String>,
209}
210
211impl ValidationIssue {
212 pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
214 Self {
215 severity: Severity::Error,
216 code,
217 message: message.into(),
218 span: None,
219 suggestion: None,
220 }
221 }
222
223 pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
225 Self {
226 severity: Severity::Warning,
227 code,
228 message: message.into(),
229 span: None,
230 suggestion: None,
231 }
232 }
233
234 pub fn with_span(mut self, span: Span) -> Self {
236 self.span = Some(span);
237 self
238 }
239
240 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
242 self.suggestion = Some(suggestion.into());
243 self
244 }
245
246 pub fn format(&self, source: &str) -> String {
250 let mut result = String::new();
251
252 if let Some(span) = &self.span {
254 let loc = span.format_location(source);
255 result.push_str(&format!("{}: ", loc));
256 }
257
258 result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
260
261 if let Some(suggestion) = &self.suggestion {
263 result.push_str(&format!("\n → {}", suggestion));
264 }
265
266 if let Some(span) = &self.span
268 && let Some(line_content) = get_line_at_offset(source, span.start) {
269 result.push_str(&format!("\n | {}", line_content));
270 }
271
272 result
273 }
274}
275
276impl fmt::Display for ValidationIssue {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
279 }
280}
281
282fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
284 if offset >= source.len() {
285 return None;
286 }
287
288 let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
289 let end = source[offset..]
290 .find('\n')
291 .map_or(source.len(), |i| offset + i);
292
293 Some(&source[start..end])
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn span_to_line_col_single_line() {
302 let source = "echo hello world";
303 let span = Span::new(5, 10);
304 assert_eq!(span.to_line_col(source), (1, 6));
305 }
306
307 #[test]
308 fn span_to_line_col_multi_line() {
309 let source = "line one\nline two\nline three";
310 let span = Span::new(18, 22);
312 assert_eq!(span.to_line_col(source), (3, 1));
313 }
314
315 #[test]
316 fn span_format_location() {
317 let source = "first\nsecond\nthird";
318 let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
320 }
321
322 #[test]
323 fn issue_formatting() {
324 let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
325 .with_span(Span::new(0, 3))
326 .with_suggestion("did you mean 'for'?");
327
328 let source = "foo bar";
329 let formatted = issue.format(source);
330
331 assert!(formatted.contains("1:1"));
332 assert!(formatted.contains("error"));
333 assert!(formatted.contains("E001"));
334 assert!(formatted.contains("command 'foo' not found"));
335 assert!(formatted.contains("did you mean 'for'?"));
336 }
337
338 #[test]
339 fn get_line_at_offset_works() {
340 let source = "line one\nline two\nline three";
341 assert_eq!(get_line_at_offset(source, 0), Some("line one"));
342 assert_eq!(get_line_at_offset(source, 9), Some("line two"));
343 assert_eq!(get_line_at_offset(source, 18), Some("line three"));
344 }
345}