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 PosixTestCommand,
61}
62
63impl IssueCode {
64 pub fn code(&self) -> &'static str {
71 match self {
72 IssueCode::UndefinedCommand => "E001",
73 IssueCode::MissingRequiredArg => "E002",
74 IssueCode::UnknownFlag => "W001",
75 IssueCode::InvalidArgType => "E003",
76 IssueCode::SeqZeroIncrement => "E004",
77 IssueCode::InvalidRegex => "E005",
78 IssueCode::InvalidSedExpr => "E006",
79 IssueCode::InvalidJqFilter => "E007",
80 IssueCode::BreakOutsideLoop => "E008",
81 IssueCode::ReturnOutsideFunction => "E009",
82 IssueCode::PossiblyUndefinedVariable => "W002",
84 IssueCode::DiffNeedsTwoFiles => "E011",
85 IssueCode::ForLoopScalarVar => "E012",
86 IssueCode::ScatterWithoutGather => "E014",
87 IssueCode::LastResultFieldAccess => "E015",
88 IssueCode::PosixTestCommand => "W006",
89 }
90 }
91
92 pub fn surfaces_to_agent(&self) -> bool {
100 matches!(self, IssueCode::PosixTestCommand)
101 }
102
103 pub fn default_severity(&self) -> Severity {
105 match self {
106 IssueCode::SeqZeroIncrement
108 | IssueCode::InvalidRegex
109 | IssueCode::InvalidSedExpr
110 | IssueCode::InvalidJqFilter
111 | IssueCode::DiffNeedsTwoFiles
112 | IssueCode::BreakOutsideLoop
113 | IssueCode::ReturnOutsideFunction
114 | IssueCode::ForLoopScalarVar
115 | IssueCode::ScatterWithoutGather
116 | IssueCode::LastResultFieldAccess => Severity::Error,
117
118 IssueCode::MissingRequiredArg
123 | IssueCode::InvalidArgType
124 | IssueCode::UndefinedCommand
125 | IssueCode::UnknownFlag
126 | IssueCode::PosixTestCommand
127 | IssueCode::PossiblyUndefinedVariable => Severity::Warning,
128 }
129 }
130}
131
132impl fmt::Display for IssueCode {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 write!(f, "{}", self.code())
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140pub struct Span {
141 pub start: usize,
143 pub end: usize,
145}
146
147impl Span {
148 pub fn new(start: usize, end: usize) -> Self {
150 Self { start, end }
151 }
152
153 pub fn to_line_col(&self, source: &str) -> (usize, usize) {
157 let mut line = 1;
158 let mut col = 1;
159
160 for (i, ch) in source.char_indices() {
161 if i >= self.start {
162 break;
163 }
164 if ch == '\n' {
165 line += 1;
166 col = 1;
167 } else {
168 col += 1;
169 }
170 }
171
172 (line, col)
173 }
174
175 pub fn format_location(&self, source: &str) -> String {
177 let (line, col) = self.to_line_col(source);
178 format!("{}:{}", line, col)
179 }
180}
181
182#[derive(Debug, Clone)]
184#[non_exhaustive]
185pub struct ValidationIssue {
186 pub severity: Severity,
188 pub code: IssueCode,
190 pub message: String,
192 pub span: Option<Span>,
194 pub suggestion: Option<String>,
196}
197
198impl ValidationIssue {
199 pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
201 Self {
202 severity: Severity::Error,
203 code,
204 message: message.into(),
205 span: None,
206 suggestion: None,
207 }
208 }
209
210 pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
212 Self {
213 severity: Severity::Warning,
214 code,
215 message: message.into(),
216 span: None,
217 suggestion: None,
218 }
219 }
220
221 pub fn with_span(mut self, span: Span) -> Self {
223 self.span = Some(span);
224 self
225 }
226
227 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
229 self.suggestion = Some(suggestion.into());
230 self
231 }
232
233 pub fn format(&self, source: &str) -> String {
237 let mut result = String::new();
238
239 if let Some(span) = &self.span {
241 let loc = span.format_location(source);
242 result.push_str(&format!("{}: ", loc));
243 }
244
245 result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
247
248 if let Some(suggestion) = &self.suggestion {
250 result.push_str(&format!("\n → {}", suggestion));
251 }
252
253 if let Some(span) = &self.span
255 && let Some(line_content) = get_line_at_offset(source, span.start) {
256 result.push_str(&format!("\n | {}", line_content));
257 }
258
259 result
260 }
261}
262
263impl fmt::Display for ValidationIssue {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
266 }
267}
268
269fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
271 if offset >= source.len() {
272 return None;
273 }
274
275 let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
276 let end = source[offset..]
277 .find('\n')
278 .map_or(source.len(), |i| offset + i);
279
280 Some(&source[start..end])
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn span_to_line_col_single_line() {
289 let source = "echo hello world";
290 let span = Span::new(5, 10);
291 assert_eq!(span.to_line_col(source), (1, 6));
292 }
293
294 #[test]
295 fn span_to_line_col_multi_line() {
296 let source = "line one\nline two\nline three";
297 let span = Span::new(18, 22);
299 assert_eq!(span.to_line_col(source), (3, 1));
300 }
301
302 #[test]
303 fn span_format_location() {
304 let source = "first\nsecond\nthird";
305 let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
307 }
308
309 #[test]
310 fn issue_formatting() {
311 let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
312 .with_span(Span::new(0, 3))
313 .with_suggestion("did you mean 'for'?");
314
315 let source = "foo bar";
316 let formatted = issue.format(source);
317
318 assert!(formatted.contains("1:1"));
319 assert!(formatted.contains("error"));
320 assert!(formatted.contains("E001"));
321 assert!(formatted.contains("command 'foo' not found"));
322 assert!(formatted.contains("did you mean 'for'?"));
323 }
324
325 #[test]
326 fn get_line_at_offset_works() {
327 let source = "line one\nline two\nline three";
328 assert_eq!(get_line_at_offset(source, 0), Some("line one"));
329 assert_eq!(get_line_at_offset(source, 9), Some("line two"));
330 assert_eq!(get_line_at_offset(source, 18), Some("line three"));
331 }
332}