use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Error => write!(f, "error"),
Severity::Warning => write!(f, "warning"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueCode {
UndefinedCommand,
MissingRequiredArg,
UnknownFlag,
InvalidArgType,
SeqZeroIncrement,
InvalidRegex,
InvalidSedExpr,
InvalidJqFilter,
BreakOutsideLoop,
ReturnOutsideFunction,
PossiblyUndefinedVariable,
ConflictingFlags,
InvalidCount,
DiffNeedsTwoFiles,
RecursiveWithoutFlag,
ExtraPositionalArgs,
ForLoopScalarVar,
ShellGlobPattern,
ScatterWithoutGather,
LastResultFieldAccess,
}
impl IssueCode {
pub fn code(&self) -> &'static str {
match self {
IssueCode::UndefinedCommand => "E001",
IssueCode::MissingRequiredArg => "E002",
IssueCode::UnknownFlag => "W001",
IssueCode::InvalidArgType => "E003",
IssueCode::SeqZeroIncrement => "E004",
IssueCode::InvalidRegex => "E005",
IssueCode::InvalidSedExpr => "E006",
IssueCode::InvalidJqFilter => "E007",
IssueCode::BreakOutsideLoop => "E008",
IssueCode::ReturnOutsideFunction => "E009",
IssueCode::PossiblyUndefinedVariable => "W002",
IssueCode::ConflictingFlags => "W003",
IssueCode::InvalidCount => "E010",
IssueCode::DiffNeedsTwoFiles => "E011",
IssueCode::RecursiveWithoutFlag => "W004",
IssueCode::ExtraPositionalArgs => "W005",
IssueCode::ForLoopScalarVar => "E012",
IssueCode::ShellGlobPattern => "E013",
IssueCode::ScatterWithoutGather => "E014",
IssueCode::LastResultFieldAccess => "E015",
}
}
pub fn default_severity(&self) -> Severity {
match self {
IssueCode::SeqZeroIncrement
| IssueCode::InvalidRegex
| IssueCode::InvalidSedExpr
| IssueCode::InvalidJqFilter
| IssueCode::BreakOutsideLoop
| IssueCode::ReturnOutsideFunction
| IssueCode::InvalidCount
| IssueCode::DiffNeedsTwoFiles
| IssueCode::ForLoopScalarVar
| IssueCode::ShellGlobPattern
| IssueCode::ScatterWithoutGather
| IssueCode::LastResultFieldAccess => Severity::Error,
IssueCode::MissingRequiredArg
| IssueCode::InvalidArgType
| IssueCode::UndefinedCommand
| IssueCode::UnknownFlag
| IssueCode::PossiblyUndefinedVariable
| IssueCode::ConflictingFlags
| IssueCode::RecursiveWithoutFlag
| IssueCode::ExtraPositionalArgs => Severity::Warning,
}
}
}
impl fmt::Display for IssueCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.code())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub fn to_line_col(&self, source: &str) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
for (i, ch) in source.char_indices() {
if i >= self.start {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
pub fn format_location(&self, source: &str) -> String {
let (line, col) = self.to_line_col(source);
format!("{}:{}", line, col)
}
}
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub severity: Severity,
pub code: IssueCode,
pub message: String,
pub span: Option<Span>,
pub suggestion: Option<String>,
}
impl ValidationIssue {
pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
code,
message: message.into(),
span: None,
suggestion: None,
}
}
pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
Self {
severity: Severity::Warning,
code,
message: message.into(),
span: None,
suggestion: None,
}
}
pub fn with_span(mut self, span: Span) -> Self {
self.span = Some(span);
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn format(&self, source: &str) -> String {
let mut result = String::new();
if let Some(span) = &self.span {
let loc = span.format_location(source);
result.push_str(&format!("{}: ", loc));
}
result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
if let Some(suggestion) = &self.suggestion {
result.push_str(&format!("\n → {}", suggestion));
}
if let Some(span) = &self.span
&& let Some(line_content) = get_line_at_offset(source, span.start) {
result.push_str(&format!("\n | {}", line_content));
}
result
}
}
impl fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
}
}
fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
if offset >= source.len() {
return None;
}
let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
let end = source[offset..]
.find('\n')
.map_or(source.len(), |i| offset + i);
Some(&source[start..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn span_to_line_col_single_line() {
let source = "echo hello world";
let span = Span::new(5, 10);
assert_eq!(span.to_line_col(source), (1, 6));
}
#[test]
fn span_to_line_col_multi_line() {
let source = "line one\nline two\nline three";
let span = Span::new(18, 22);
assert_eq!(span.to_line_col(source), (3, 1));
}
#[test]
fn span_format_location() {
let source = "first\nsecond\nthird";
let span = Span::new(6, 12); assert_eq!(span.format_location(source), "2:1");
}
#[test]
fn issue_formatting() {
let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
.with_span(Span::new(0, 3))
.with_suggestion("did you mean 'for'?");
let source = "foo bar";
let formatted = issue.format(source);
assert!(formatted.contains("1:1"));
assert!(formatted.contains("error"));
assert!(formatted.contains("E001"));
assert!(formatted.contains("command 'foo' not found"));
assert!(formatted.contains("did you mean 'for'?"));
}
#[test]
fn get_line_at_offset_works() {
let source = "line one\nline two\nline three";
assert_eq!(get_line_at_offset(source, 0), Some("line one"));
assert_eq!(get_line_at_offset(source, 9), Some("line two"));
assert_eq!(get_line_at_offset(source, 18), Some("line three"));
}
}