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}
63
64impl IssueCode {
65 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",
79 IssueCode::ConflictingFlags => "W003",
80 IssueCode::InvalidCount => "E010",
81 IssueCode::DiffNeedsTwoFiles => "E011",
82 IssueCode::RecursiveWithoutFlag => "W004",
83 IssueCode::ExtraPositionalArgs => "W005",
84 IssueCode::ForLoopScalarVar => "E012",
85 IssueCode::ShellGlobPattern => "E013",
86 }
87 }
88
89 pub fn default_severity(&self) -> Severity {
91 match self {
92 IssueCode::SeqZeroIncrement
94 | IssueCode::InvalidRegex
95 | IssueCode::InvalidSedExpr
96 | IssueCode::InvalidJqFilter
97 | IssueCode::BreakOutsideLoop
98 | IssueCode::ReturnOutsideFunction
99 | IssueCode::InvalidCount
100 | IssueCode::DiffNeedsTwoFiles
101 | IssueCode::ForLoopScalarVar
102 | IssueCode::ShellGlobPattern => Severity::Error,
103
104 IssueCode::MissingRequiredArg
109 | IssueCode::InvalidArgType
110 | IssueCode::UndefinedCommand
111 | IssueCode::UnknownFlag
112 | IssueCode::PossiblyUndefinedVariable
113 | IssueCode::ConflictingFlags
114 | IssueCode::RecursiveWithoutFlag
115 | IssueCode::ExtraPositionalArgs => Severity::Warning,
116 }
117 }
118}
119
120impl fmt::Display for IssueCode {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 write!(f, "{}", self.code())
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub struct Span {
129 pub start: usize,
131 pub end: usize,
133}
134
135impl Span {
136 pub fn new(start: usize, end: usize) -> Self {
138 Self { start, end }
139 }
140
141 pub fn to_line_col(&self, source: &str) -> (usize, usize) {
145 let mut line = 1;
146 let mut col = 1;
147
148 for (i, ch) in source.char_indices() {
149 if i >= self.start {
150 break;
151 }
152 if ch == '\n' {
153 line += 1;
154 col = 1;
155 } else {
156 col += 1;
157 }
158 }
159
160 (line, col)
161 }
162
163 pub fn format_location(&self, source: &str) -> String {
165 let (line, col) = self.to_line_col(source);
166 format!("{}:{}", line, col)
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct ValidationIssue {
173 pub severity: Severity,
175 pub code: IssueCode,
177 pub message: String,
179 pub span: Option<Span>,
181 pub suggestion: Option<String>,
183}
184
185impl ValidationIssue {
186 pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
188 Self {
189 severity: Severity::Error,
190 code,
191 message: message.into(),
192 span: None,
193 suggestion: None,
194 }
195 }
196
197 pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
199 Self {
200 severity: Severity::Warning,
201 code,
202 message: message.into(),
203 span: None,
204 suggestion: None,
205 }
206 }
207
208 pub fn with_span(mut self, span: Span) -> Self {
210 self.span = Some(span);
211 self
212 }
213
214 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
216 self.suggestion = Some(suggestion.into());
217 self
218 }
219
220 pub fn format(&self, source: &str) -> String {
224 let mut result = String::new();
225
226 if let Some(span) = &self.span {
228 let loc = span.format_location(source);
229 result.push_str(&format!("{}: ", loc));
230 }
231
232 result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
234
235 if let Some(suggestion) = &self.suggestion {
237 result.push_str(&format!("\n → {}", suggestion));
238 }
239
240 if let Some(span) = &self.span
242 && let Some(line_content) = get_line_at_offset(source, span.start) {
243 result.push_str(&format!("\n | {}", line_content));
244 }
245
246 result
247 }
248}
249
250impl fmt::Display for ValidationIssue {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
253 }
254}
255
256fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
258 if offset >= source.len() {
259 return None;
260 }
261
262 let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
263 let end = source[offset..]
264 .find('\n')
265 .map_or(source.len(), |i| offset + i);
266
267 Some(&source[start..end])
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn span_to_line_col_single_line() {
276 let source = "echo hello world";
277 let span = Span::new(5, 10);
278 assert_eq!(span.to_line_col(source), (1, 6));
279 }
280
281 #[test]
282 fn span_to_line_col_multi_line() {
283 let source = "line one\nline two\nline three";
284 let span = Span::new(18, 22);
286 assert_eq!(span.to_line_col(source), (3, 1));
287 }
288
289 #[test]
290 fn span_format_location() {
291 let source = "first\nsecond\nthird";
292 let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
294 }
295
296 #[test]
297 fn issue_formatting() {
298 let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
299 .with_span(Span::new(0, 3))
300 .with_suggestion("did you mean 'for'?");
301
302 let source = "foo bar";
303 let formatted = issue.format(source);
304
305 assert!(formatted.contains("1:1"));
306 assert!(formatted.contains("error"));
307 assert!(formatted.contains("E001"));
308 assert!(formatted.contains("command 'foo' not found"));
309 assert!(formatted.contains("did you mean 'for'?"));
310 }
311
312 #[test]
313 fn get_line_at_offset_works() {
314 let source = "line one\nline two\nline three";
315 assert_eq!(get_line_at_offset(source, 0), Some("line one"));
316 assert_eq!(get_line_at_offset(source, 9), Some("line two"));
317 assert_eq!(get_line_at_offset(source, 18), Some("line three"));
318 }
319}