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