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 UnreadableAssignmentTarget,
72 InvisibleAssignmentTarget,
79 MixedScriptName,
86}
87
88impl IssueCode {
89 pub fn code(&self) -> &'static str {
98 match self {
99 IssueCode::UndefinedCommand => "E001",
100 IssueCode::MissingRequiredArg => "E002",
101 IssueCode::UnknownFlag => "W001",
102 IssueCode::InvalidArgType => "E003",
103 IssueCode::SeqZeroIncrement => "E004",
104 IssueCode::InvalidRegex => "E005",
105 IssueCode::InvalidSedExpr => "E006",
106 IssueCode::InvalidJqFilter => "E007",
107 IssueCode::BreakOutsideLoop => "E008",
108 IssueCode::ReturnOutsideFunction => "E009",
109 IssueCode::PossiblyUndefinedVariable => "W002",
111 IssueCode::DiffNeedsTwoFiles => "E011",
112 IssueCode::ForLoopScalarVar => "E012",
113 IssueCode::ScatterWithoutGather => "E014",
114 IssueCode::LastResultFieldAccess => "E015",
115 IssueCode::LvalueUndefinedRoot => "E016",
116 IssueCode::DottedAssignmentTarget => "E017",
117 IssueCode::UnreadableAssignmentTarget => "E018",
118 IssueCode::InvisibleAssignmentTarget => "E019",
119 IssueCode::MixedScriptName => "W007",
120 }
121 }
122
123 pub fn surfaces_to_agent(&self) -> bool {
136 matches!(self, IssueCode::MixedScriptName)
137 }
138
139 pub fn default_severity(&self) -> Severity {
141 match self {
142 IssueCode::SeqZeroIncrement
144 | IssueCode::InvalidRegex
145 | IssueCode::InvalidSedExpr
146 | IssueCode::InvalidJqFilter
147 | IssueCode::DiffNeedsTwoFiles
148 | IssueCode::BreakOutsideLoop
149 | IssueCode::ReturnOutsideFunction
150 | IssueCode::ForLoopScalarVar
151 | IssueCode::ScatterWithoutGather
152 | IssueCode::LastResultFieldAccess
153 | IssueCode::LvalueUndefinedRoot
154 | IssueCode::DottedAssignmentTarget
155 | IssueCode::UnreadableAssignmentTarget
156 | IssueCode::InvisibleAssignmentTarget => Severity::Error,
157
158 IssueCode::MissingRequiredArg
163 | IssueCode::InvalidArgType
164 | IssueCode::UndefinedCommand
165 | IssueCode::UnknownFlag
166 | IssueCode::PossiblyUndefinedVariable
167 | IssueCode::MixedScriptName => Severity::Warning,
168 }
169 }
170}
171
172impl fmt::Display for IssueCode {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 write!(f, "{}", self.code())
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub struct Span {
181 pub start: usize,
183 pub end: usize,
185}
186
187impl Span {
188 pub fn new(start: usize, end: usize) -> Self {
190 Self { start, end }
191 }
192
193 pub fn to_line_col(&self, source: &str) -> (usize, usize) {
197 let mut line = 1;
198 let mut col = 1;
199
200 for (i, ch) in source.char_indices() {
201 if i >= self.start {
202 break;
203 }
204 if ch == '\n' {
205 line += 1;
206 col = 1;
207 } else {
208 col += 1;
209 }
210 }
211
212 (line, col)
213 }
214
215 pub fn format_location(&self, source: &str) -> String {
217 let (line, col) = self.to_line_col(source);
218 format!("{}:{}", line, col)
219 }
220}
221
222#[derive(Debug, Clone)]
224#[non_exhaustive]
225pub struct ValidationIssue {
226 pub severity: Severity,
228 pub code: IssueCode,
230 pub message: String,
232 pub span: Option<Span>,
234 pub suggestion: Option<String>,
236}
237
238impl ValidationIssue {
239 pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
241 Self {
242 severity: Severity::Error,
243 code,
244 message: message.into(),
245 span: None,
246 suggestion: None,
247 }
248 }
249
250 pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
252 Self {
253 severity: Severity::Warning,
254 code,
255 message: message.into(),
256 span: None,
257 suggestion: None,
258 }
259 }
260
261 pub fn with_span(mut self, span: Span) -> Self {
263 self.span = Some(span);
264 self
265 }
266
267 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
269 self.suggestion = Some(suggestion.into());
270 self
271 }
272
273 pub fn format(&self, source: &str) -> String {
277 let mut result = String::new();
278
279 if let Some(span) = &self.span {
281 let loc = span.format_location(source);
282 result.push_str(&format!("{}: ", loc));
283 }
284
285 result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
287
288 if let Some(suggestion) = &self.suggestion {
290 result.push_str(&format!("\n → {}", suggestion));
291 }
292
293 if let Some(span) = &self.span
295 && let Some(line_content) = get_line_at_offset(source, span.start) {
296 result.push_str(&format!("\n | {}", line_content));
297 }
298
299 result
300 }
301}
302
303impl fmt::Display for ValidationIssue {
304 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
306 }
307}
308
309fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
311 if offset >= source.len() {
312 return None;
313 }
314
315 let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
316 let end = source[offset..]
317 .find('\n')
318 .map_or(source.len(), |i| offset + i);
319
320 Some(&source[start..end])
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn span_to_line_col_single_line() {
329 let source = "echo hello world";
330 let span = Span::new(5, 10);
331 assert_eq!(span.to_line_col(source), (1, 6));
332 }
333
334 #[test]
335 fn span_to_line_col_multi_line() {
336 let source = "line one\nline two\nline three";
337 let span = Span::new(18, 22);
339 assert_eq!(span.to_line_col(source), (3, 1));
340 }
341
342 #[test]
343 fn span_format_location() {
344 let source = "first\nsecond\nthird";
345 let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
347 }
348
349 #[test]
350 fn issue_formatting() {
351 let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
352 .with_span(Span::new(0, 3))
353 .with_suggestion("did you mean 'for'?");
354
355 let source = "foo bar";
356 let formatted = issue.format(source);
357
358 assert!(formatted.contains("1:1"));
359 assert!(formatted.contains("error"));
360 assert!(formatted.contains("E001"));
361 assert!(formatted.contains("command 'foo' not found"));
362 assert!(formatted.contains("did you mean 'for'?"));
363 }
364
365 #[test]
366 fn get_line_at_offset_works() {
367 let source = "line one\nline two\nline three";
368 assert_eq!(get_line_at_offset(source, 0), Some("line one"));
369 assert_eq!(get_line_at_offset(source, 9), Some("line two"));
370 assert_eq!(get_line_at_offset(source, 18), Some("line three"));
371 }
372}