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