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 InvalidSedExpr,
40 InvalidJqFilter,
42 BreakOutsideLoop,
44 ReturnOutsideFunction,
46 PossiblyUndefinedVariable,
48 ConflictingFlags,
50 InvalidCount,
52 DiffNeedsTwoFiles,
54 RecursiveWithoutFlag,
56 ExtraPositionalArgs,
58 ForLoopScalarVar,
60 ScatterWithoutGather,
62 LastResultFieldAccess,
65}
66
67impl IssueCode {
68 pub fn code(&self) -> &'static str {
70 match self {
71 IssueCode::UndefinedCommand => "E001",
72 IssueCode::MissingRequiredArg => "E002",
73 IssueCode::UnknownFlag => "W001",
74 IssueCode::InvalidArgType => "E003",
75 IssueCode::SeqZeroIncrement => "E004",
76 IssueCode::InvalidRegex => "E005",
77 IssueCode::InvalidSedExpr => "E006",
78 IssueCode::InvalidJqFilter => "E007",
79 IssueCode::BreakOutsideLoop => "E008",
80 IssueCode::ReturnOutsideFunction => "E009",
81 IssueCode::PossiblyUndefinedVariable => "W002",
82 IssueCode::ConflictingFlags => "W003",
83 IssueCode::InvalidCount => "E010",
84 IssueCode::DiffNeedsTwoFiles => "E011",
85 IssueCode::RecursiveWithoutFlag => "W004",
86 IssueCode::ExtraPositionalArgs => "W005",
87 IssueCode::ForLoopScalarVar => "E012",
88 IssueCode::ScatterWithoutGather => "E014",
89 IssueCode::LastResultFieldAccess => "E015",
90 }
91 }
92
93 pub fn default_severity(&self) -> Severity {
95 match self {
96 IssueCode::SeqZeroIncrement
98 | IssueCode::InvalidRegex
99 | IssueCode::InvalidSedExpr
100 | IssueCode::InvalidJqFilter
101 | IssueCode::BreakOutsideLoop
102 | IssueCode::ReturnOutsideFunction
103 | IssueCode::InvalidCount
104 | IssueCode::DiffNeedsTwoFiles
105 | IssueCode::ForLoopScalarVar
106 | IssueCode::ScatterWithoutGather
107 | IssueCode::LastResultFieldAccess => Severity::Error,
108
109 IssueCode::MissingRequiredArg
114 | IssueCode::InvalidArgType
115 | IssueCode::UndefinedCommand
116 | IssueCode::UnknownFlag
117 | IssueCode::PossiblyUndefinedVariable
118 | IssueCode::ConflictingFlags
119 | IssueCode::RecursiveWithoutFlag
120 | IssueCode::ExtraPositionalArgs => Severity::Warning,
121 }
122 }
123}
124
125impl fmt::Display for IssueCode {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 write!(f, "{}", self.code())
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub struct Span {
134 pub start: usize,
136 pub end: usize,
138}
139
140impl Span {
141 pub fn new(start: usize, end: usize) -> Self {
143 Self { start, end }
144 }
145
146 pub fn to_line_col(&self, source: &str) -> (usize, usize) {
150 let mut line = 1;
151 let mut col = 1;
152
153 for (i, ch) in source.char_indices() {
154 if i >= self.start {
155 break;
156 }
157 if ch == '\n' {
158 line += 1;
159 col = 1;
160 } else {
161 col += 1;
162 }
163 }
164
165 (line, col)
166 }
167
168 pub fn format_location(&self, source: &str) -> String {
170 let (line, col) = self.to_line_col(source);
171 format!("{}:{}", line, col)
172 }
173}
174
175#[derive(Debug, Clone)]
177#[non_exhaustive]
178pub struct ValidationIssue {
179 pub severity: Severity,
181 pub code: IssueCode,
183 pub message: String,
185 pub span: Option<Span>,
187 pub suggestion: Option<String>,
189}
190
191impl ValidationIssue {
192 pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
194 Self {
195 severity: Severity::Error,
196 code,
197 message: message.into(),
198 span: None,
199 suggestion: None,
200 }
201 }
202
203 pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
205 Self {
206 severity: Severity::Warning,
207 code,
208 message: message.into(),
209 span: None,
210 suggestion: None,
211 }
212 }
213
214 pub fn with_span(mut self, span: Span) -> Self {
216 self.span = Some(span);
217 self
218 }
219
220 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
222 self.suggestion = Some(suggestion.into());
223 self
224 }
225
226 pub fn format(&self, source: &str) -> String {
230 let mut result = String::new();
231
232 if let Some(span) = &self.span {
234 let loc = span.format_location(source);
235 result.push_str(&format!("{}: ", loc));
236 }
237
238 result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
240
241 if let Some(suggestion) = &self.suggestion {
243 result.push_str(&format!("\n → {}", suggestion));
244 }
245
246 if let Some(span) = &self.span
248 && let Some(line_content) = get_line_at_offset(source, span.start) {
249 result.push_str(&format!("\n | {}", line_content));
250 }
251
252 result
253 }
254}
255
256impl fmt::Display for ValidationIssue {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
259 }
260}
261
262fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
264 if offset >= source.len() {
265 return None;
266 }
267
268 let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
269 let end = source[offset..]
270 .find('\n')
271 .map_or(source.len(), |i| offset + i);
272
273 Some(&source[start..end])
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn span_to_line_col_single_line() {
282 let source = "echo hello world";
283 let span = Span::new(5, 10);
284 assert_eq!(span.to_line_col(source), (1, 6));
285 }
286
287 #[test]
288 fn span_to_line_col_multi_line() {
289 let source = "line one\nline two\nline three";
290 let span = Span::new(18, 22);
292 assert_eq!(span.to_line_col(source), (3, 1));
293 }
294
295 #[test]
296 fn span_format_location() {
297 let source = "first\nsecond\nthird";
298 let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
300 }
301
302 #[test]
303 fn issue_formatting() {
304 let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
305 .with_span(Span::new(0, 3))
306 .with_suggestion("did you mean 'for'?");
307
308 let source = "foo bar";
309 let formatted = issue.format(source);
310
311 assert!(formatted.contains("1:1"));
312 assert!(formatted.contains("error"));
313 assert!(formatted.contains("E001"));
314 assert!(formatted.contains("command 'foo' not found"));
315 assert!(formatted.contains("did you mean 'for'?"));
316 }
317
318 #[test]
319 fn get_line_at_offset_works() {
320 let source = "line one\nline two\nline three";
321 assert_eq!(get_line_at_offset(source, 0), Some("line one"));
322 assert_eq!(get_line_at_offset(source, 9), Some("line two"));
323 assert_eq!(get_line_at_offset(source, 18), Some("line three"));
324 }
325}