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}
58
59impl IssueCode {
60 pub fn code(&self) -> &'static str {
67 match self {
68 IssueCode::UndefinedCommand => "E001",
69 IssueCode::MissingRequiredArg => "E002",
70 IssueCode::UnknownFlag => "W001",
71 IssueCode::InvalidArgType => "E003",
72 IssueCode::SeqZeroIncrement => "E004",
73 IssueCode::InvalidRegex => "E005",
74 IssueCode::InvalidSedExpr => "E006",
75 IssueCode::InvalidJqFilter => "E007",
76 IssueCode::BreakOutsideLoop => "E008",
77 IssueCode::ReturnOutsideFunction => "E009",
78 IssueCode::PossiblyUndefinedVariable => "W002",
80 IssueCode::DiffNeedsTwoFiles => "E011",
81 IssueCode::ForLoopScalarVar => "E012",
82 IssueCode::ScatterWithoutGather => "E014",
83 IssueCode::LastResultFieldAccess => "E015",
84 }
85 }
86
87 pub fn default_severity(&self) -> Severity {
89 match self {
90 IssueCode::SeqZeroIncrement
92 | IssueCode::InvalidRegex
93 | IssueCode::InvalidSedExpr
94 | IssueCode::InvalidJqFilter
95 | IssueCode::DiffNeedsTwoFiles
96 | IssueCode::BreakOutsideLoop
97 | IssueCode::ReturnOutsideFunction
98 | IssueCode::ForLoopScalarVar
99 | IssueCode::ScatterWithoutGather
100 | IssueCode::LastResultFieldAccess => Severity::Error,
101
102 IssueCode::MissingRequiredArg
107 | IssueCode::InvalidArgType
108 | IssueCode::UndefinedCommand
109 | IssueCode::UnknownFlag
110 | IssueCode::PossiblyUndefinedVariable => Severity::Warning,
111 }
112 }
113}
114
115impl fmt::Display for IssueCode {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 write!(f, "{}", self.code())
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub struct Span {
124 pub start: usize,
126 pub end: usize,
128}
129
130impl Span {
131 pub fn new(start: usize, end: usize) -> Self {
133 Self { start, end }
134 }
135
136 pub fn to_line_col(&self, source: &str) -> (usize, usize) {
140 let mut line = 1;
141 let mut col = 1;
142
143 for (i, ch) in source.char_indices() {
144 if i >= self.start {
145 break;
146 }
147 if ch == '\n' {
148 line += 1;
149 col = 1;
150 } else {
151 col += 1;
152 }
153 }
154
155 (line, col)
156 }
157
158 pub fn format_location(&self, source: &str) -> String {
160 let (line, col) = self.to_line_col(source);
161 format!("{}:{}", line, col)
162 }
163}
164
165#[derive(Debug, Clone)]
167#[non_exhaustive]
168pub struct ValidationIssue {
169 pub severity: Severity,
171 pub code: IssueCode,
173 pub message: String,
175 pub span: Option<Span>,
177 pub suggestion: Option<String>,
179}
180
181impl ValidationIssue {
182 pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
184 Self {
185 severity: Severity::Error,
186 code,
187 message: message.into(),
188 span: None,
189 suggestion: None,
190 }
191 }
192
193 pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
195 Self {
196 severity: Severity::Warning,
197 code,
198 message: message.into(),
199 span: None,
200 suggestion: None,
201 }
202 }
203
204 pub fn with_span(mut self, span: Span) -> Self {
206 self.span = Some(span);
207 self
208 }
209
210 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
212 self.suggestion = Some(suggestion.into());
213 self
214 }
215
216 pub fn format(&self, source: &str) -> String {
220 let mut result = String::new();
221
222 if let Some(span) = &self.span {
224 let loc = span.format_location(source);
225 result.push_str(&format!("{}: ", loc));
226 }
227
228 result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
230
231 if let Some(suggestion) = &self.suggestion {
233 result.push_str(&format!("\n → {}", suggestion));
234 }
235
236 if let Some(span) = &self.span
238 && let Some(line_content) = get_line_at_offset(source, span.start) {
239 result.push_str(&format!("\n | {}", line_content));
240 }
241
242 result
243 }
244}
245
246impl fmt::Display for ValidationIssue {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
249 }
250}
251
252fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
254 if offset >= source.len() {
255 return None;
256 }
257
258 let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
259 let end = source[offset..]
260 .find('\n')
261 .map_or(source.len(), |i| offset + i);
262
263 Some(&source[start..end])
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn span_to_line_col_single_line() {
272 let source = "echo hello world";
273 let span = Span::new(5, 10);
274 assert_eq!(span.to_line_col(source), (1, 6));
275 }
276
277 #[test]
278 fn span_to_line_col_multi_line() {
279 let source = "line one\nline two\nline three";
280 let span = Span::new(18, 22);
282 assert_eq!(span.to_line_col(source), (3, 1));
283 }
284
285 #[test]
286 fn span_format_location() {
287 let source = "first\nsecond\nthird";
288 let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
290 }
291
292 #[test]
293 fn issue_formatting() {
294 let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
295 .with_span(Span::new(0, 3))
296 .with_suggestion("did you mean 'for'?");
297
298 let source = "foo bar";
299 let formatted = issue.format(source);
300
301 assert!(formatted.contains("1:1"));
302 assert!(formatted.contains("error"));
303 assert!(formatted.contains("E001"));
304 assert!(formatted.contains("command 'foo' not found"));
305 assert!(formatted.contains("did you mean 'for'?"));
306 }
307
308 #[test]
309 fn get_line_at_offset_works() {
310 let source = "line one\nline two\nline three";
311 assert_eq!(get_line_at_offset(source, 0), Some("line one"));
312 assert_eq!(get_line_at_offset(source, 9), Some("line two"));
313 assert_eq!(get_line_at_offset(source, 18), Some("line three"));
314 }
315}