#![forbid(unsafe_code)]
pub mod lsp;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub line: usize,
pub character: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub start: Position,
pub end: Position,
pub severity: Severity,
pub message: String,
pub code: &'static str,
}
#[must_use]
pub fn analyse(source: &str) -> Vec<Diagnostic> {
let doc = match oxml::parse_with(source, oxml::Limits::strict()) {
Ok(doc) => doc,
Err(e) => {
let (line, column) = e.line_column(source);
let start = Position {
line: line.saturating_sub(1),
character: column.saturating_sub(1),
};
return vec![Diagnostic {
start,
end: Position {
line: start.line,
character: start.character + 1,
},
severity: Severity::Error,
message: e.to_string(),
code: "not-well-formed",
}];
}
};
let mut out = Vec::new();
lint_document(&doc, source, &mut out);
out
}
fn lint_document(
doc: &oxml::Document,
source: &str,
out: &mut Vec<Diagnostic>,
) {
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
for id in doc.descendants() {
if !doc.is_element(id) {
continue;
}
if let Some(value) = doc.attribute(id, "id") {
if seen_ids.contains(value) {
let at = locate(source, value);
let first = at.line;
out.push(Diagnostic {
start: at,
end: Position {
line: at.line,
character: at.character + value.chars().count(),
},
severity: Severity::Warning,
message: format!(
"duplicate id `{value}`; first used on line {}",
first + 1
),
code: "duplicate-id",
});
} else {
let _ = seen_ids.insert(value);
}
}
}
for id in doc.descendants() {
let Some(name) = doc.element_name(id) else {
continue;
};
if doc.children(id).is_empty()
&& doc.attribute_nodes(id).is_empty()
&& doc.parent(id) != Some(doc.root())
{
let at = locate(source, &format!("<{}", name.local));
out.push(Diagnostic {
start: at,
end: Position {
line: at.line,
character: at.character + name.local.chars().count() + 1,
},
severity: Severity::Hint,
message: format!(
"`{}` is empty and has no attributes",
name.local
),
code: "empty-element",
});
}
}
}
fn locate(source: &str, needle: &str) -> Position {
source.find(needle).map_or(
Position {
line: 0,
character: 0,
},
|offset| {
let before = &source[..offset];
Position {
line: before.matches('\n').count(),
character: before
.rsplit('\n')
.next()
.map_or(0, |l| l.chars().count()),
}
},
)
}