use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
Info,
Hint,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Error => write!(f, "error"),
Severity::Warning => write!(f, "warning"),
Severity::Info => write!(f, "info"),
Severity::Hint => write!(f, "hint"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Span {
pub offset: usize,
pub length: usize,
}
impl Span {
pub fn new(offset: usize, length: usize) -> Self {
Self { offset, length }
}
pub fn from_range(start: usize, end: usize) -> Self {
debug_assert!(end >= start, "Span end must be >= start");
Self {
offset: start,
length: end - start,
}
}
pub fn end(&self) -> usize {
self.offset + self.length
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SourceLocation {
pub line: usize,
pub column: usize,
pub offset: usize,
}
impl SourceLocation {
pub fn new(line: usize, column: usize, offset: usize) -> Self {
Self {
line,
column,
offset,
}
}
pub fn from_offset(source: &str, offset: usize) -> Self {
let mut line = 1;
let mut col = 1;
for (i, ch) in source.char_indices() {
if i == offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
Self {
line,
column: col,
offset,
}
}
}
pub struct SourceLineIndex {
line_starts: Vec<usize>,
}
impl SourceLineIndex {
pub fn build(source: &str) -> Self {
let mut line_starts = vec![0usize];
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
line_starts.push(i + 1);
}
}
Self { line_starts }
}
pub fn offset_to_location(&self, offset: usize) -> (usize, usize) {
let line_idx = match self.line_starts.binary_search(&offset) {
Ok(exact) => exact,
Err(insert) => insert - 1,
};
let line = line_idx + 1; let col = offset - self.line_starts[line_idx] + 1; (line, col)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Edit {
pub span: Span,
pub new_text: String,
}
impl Edit {
pub fn new(span: Span, new_text: impl Into<String>) -> Self {
Self {
span,
new_text: new_text.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fix {
pub description: String,
pub edits: Vec<Edit>,
}
impl Fix {
pub fn new(description: impl Into<String>, edits: Vec<Edit>) -> Self {
Self {
description: description.into(),
edits,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnostic {
pub rule_name: String,
pub message: String,
pub severity: Severity,
pub span: Span,
pub file_path: String,
pub fix: Option<Fix>,
}
impl Diagnostic {
pub fn new(rule_name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
rule_name: rule_name.into(),
message: message.into(),
severity: Severity::Warning,
span: Span::new(0, 0),
file_path: String::new(),
fix: None,
}
}
pub fn severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
pub fn span(mut self, span: Span) -> Self {
self.span = span;
self
}
pub fn file_path(mut self, path: impl Into<String>) -> Self {
self.file_path = path.into();
self
}
pub fn fix(mut self, fix: Fix) -> Self {
self.fix = Some(fix);
self
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} ({})",
self.severity, self.message, self.rule_name
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LintResult {
pub file_path: String,
pub diagnostics: Vec<Diagnostic>,
pub source: String,
}
impl LintResult {
pub fn new(
file_path: impl Into<String>,
source: impl Into<String>,
diagnostics: Vec<Diagnostic>,
) -> Self {
Self {
file_path: file_path.into(),
source: source.into(),
diagnostics,
}
}
pub fn is_empty(&self) -> bool {
self.diagnostics.is_empty()
}
pub fn count_by_severity(&self, severity: Severity) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == severity)
.count()
}
}
pub fn apply_fixes(source: &str, diagnostics: &[Diagnostic]) -> (String, usize) {
let mut edits: Vec<&Edit> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref())
.flat_map(|f| &f.edits)
.collect();
if edits.is_empty() {
return (source.to_string(), 0);
}
edits.sort_by(|a, b| b.span.offset.cmp(&a.span.offset));
let mut result = source.to_string();
let mut applied = 0;
let mut last_offset = usize::MAX;
for edit in &edits {
let start = edit.span.offset;
let end = start + edit.span.length;
if end > last_offset {
continue;
}
if end > result.len() {
continue;
}
if !result.is_char_boundary(start) || !result.is_char_boundary(end) {
continue;
}
result.replace_range(start..end, &edit.new_text);
last_offset = start;
applied += 1;
}
(result, applied)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn span_from_range() {
let span = Span::from_range(10, 20);
assert_eq!(span.offset, 10);
assert_eq!(span.length, 10);
assert_eq!(span.end(), 20);
}
#[test]
fn source_location_from_offset() {
let src = "abc\ndef\nghi";
let loc = SourceLocation::from_offset(src, 5); assert_eq!(loc.line, 2);
assert_eq!(loc.column, 2);
}
#[test]
fn diagnostic_builder() {
let diag = Diagnostic::new("color-no-invalid-hex", "Invalid hex color")
.severity(Severity::Error)
.span(Span::new(12, 4))
.file_path("test.css");
assert_eq!(diag.rule_name, "color-no-invalid-hex");
assert_eq!(diag.severity, Severity::Error);
assert_eq!(diag.span.offset, 12);
assert_eq!(diag.file_path, "test.css");
assert!(diag.fix.is_none());
}
#[test]
fn diagnostic_with_fix() {
let fix = Fix::new(
"Replace with valid hex",
vec![Edit::new(Span::new(12, 4), "#fff")],
);
let diag = Diagnostic::new("color-no-invalid-hex", "Invalid hex color").fix(fix);
assert!(diag.fix.is_some());
assert_eq!(diag.fix.as_ref().unwrap().edits.len(), 1);
}
#[test]
fn lint_result_helpers() {
let result = LintResult::new("test.css", "body { color: red; }", vec![]);
assert!(result.is_empty());
assert_eq!(result.count_by_severity(Severity::Error), 0);
}
#[test]
fn apply_fixes_single_edit() {
let source = "a { color: #ffffff; }";
let diags = vec![
Diagnostic::new("color-hex-length", "shorten")
.span(Span::new(11, 7))
.fix(Fix::new(
"Shorten hex",
vec![Edit::new(Span::new(11, 7), "#fff")],
)),
];
let (fixed, count) = apply_fixes(source, &diags);
assert_eq!(fixed, "a { color: #fff; }");
assert_eq!(count, 1);
}
#[test]
fn apply_fixes_multiple_edits() {
let source = "a { margin: 0px; padding: 0em; }";
let diags = vec![
Diagnostic::new("length-zero-no-unit", "remove unit")
.span(Span::new(12, 3))
.fix(Fix::new(
"Remove unit",
vec![Edit::new(Span::new(12, 3), "0")],
)),
Diagnostic::new("length-zero-no-unit", "remove unit")
.span(Span::new(26, 3))
.fix(Fix::new(
"Remove unit",
vec![Edit::new(Span::new(26, 3), "0")],
)),
];
let (fixed, count) = apply_fixes(source, &diags);
assert_eq!(fixed, "a { margin: 0; padding: 0; }");
assert_eq!(count, 2);
}
#[test]
fn apply_fixes_no_fixes() {
let source = "a { color: red; }";
let diags = vec![Diagnostic::new("some-rule", "no fix available").span(Span::new(0, 1))];
let (fixed, count) = apply_fixes(source, &diags);
assert_eq!(fixed, source);
assert_eq!(count, 0);
}
}