pub mod config;
pub mod parser;
pub mod rules;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::OnceLock;
use libyaml_safer::{EventData, Parser};
use regex::Regex;
pub use config::Config;
use parser::{Elem, Stream};
use rules::{Check, Ctx, States};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Warning,
Error,
}
impl Level {
pub const fn as_str(self) -> &'static str {
match self {
Self::Warning => "warning",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem {
pub line: usize,
pub column: usize,
pub desc: String,
pub rule: Option<&'static str>,
pub level: Level,
}
impl Problem {
pub fn new(line: usize, column: usize, desc: impl Into<String>) -> Self {
Self {
line,
column,
desc: desc.into(),
rule: None,
level: Level::Error,
}
}
pub fn message(&self) -> String {
match self.rule {
Some(rule) => format!("{} ({rule})", self.desc),
None => self.desc.clone(),
}
}
}
impl std::fmt::Display for Problem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}:{}: [{}] {}",
self.line,
self.column,
self.level.as_str(),
self.message()
)
}
}
fn regex(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {
cell.get_or_init(|| Regex::new(pattern).expect("yamllint directive pattern compiles"))
}
struct DisableDirective {
rules: BTreeSet<&'static str>,
all_rules: BTreeSet<&'static str>,
}
fn directive_rules(comment: &str, keyword_len: usize) -> impl Iterator<Item = &str> {
comment[keyword_len..]
.trim_end()
.split(' ')
.skip(1)
.map(|item| item.get(5..).unwrap_or(""))
}
impl DisableDirective {
const fn new(all_rules: BTreeSet<&'static str>) -> Self {
Self {
rules: BTreeSet::new(),
all_rules,
}
}
fn disable(&mut self, comment: &str, keyword_len: usize) {
let named: Vec<&str> = directive_rules(comment, keyword_len).collect();
if named.is_empty() {
self.rules.clone_from(&self.all_rules);
} else {
for id in named {
if let Some(known) = self.all_rules.get(id) {
self.rules.insert(known);
}
}
}
}
fn process_comment(&mut self, comment: &str) {
static DISABLE: OnceLock<Regex> = OnceLock::new();
static ENABLE: OnceLock<Regex> = OnceLock::new();
if regex(&DISABLE, r"^# yamllint disable( rule:\S+)*\s*$").is_match(comment) {
self.disable(comment, "# yamllint disable".len());
} else if regex(&ENABLE, r"^# yamllint enable( rule:\S+)*\s*$").is_match(comment) {
let named: Vec<&str> = directive_rules(comment, "# yamllint enable".len()).collect();
if named.is_empty() {
self.rules.clear();
} else {
for id in named {
self.rules.remove(id);
}
}
}
}
fn process_line_comment(&mut self, comment: &str) {
static DISABLE_LINE: OnceLock<Regex> = OnceLock::new();
if regex(&DISABLE_LINE, r"^# yamllint disable-line( rule:\S+)*\s*$").is_match(comment) {
self.disable(comment, "# yamllint disable-line".len());
}
}
fn is_disabled(&self, problem: &Problem) -> bool {
problem.rule.is_some_and(|rule| self.rules.contains(rule))
}
}
fn syntax_error(buffer: &str) -> Option<Problem> {
let mut parser: Parser<&mut &[u8]> = Parser::new();
let mut input = buffer.as_bytes();
parser.set_input_string(&mut input);
loop {
match parser.parse() {
Ok(event) => {
if matches!(event.data, EventData::StreamEnd) {
return None;
}
}
Err(e) => {
let mark = e.problem_mark().unwrap_or_default();
return Some(Problem {
line: mark.line as usize + 1,
column: mark.column as usize + 1,
desc: format!("syntax error: {} (syntax)", e.problem()),
rule: None,
level: Level::Error,
});
}
}
}
}
fn cosmetic_problems(buffer: &str, conf: &Config, filepath: Option<&Path>) -> Vec<Problem> {
let stream = Stream::new(buffer);
let enabled = conf.enabled_rules(filepath);
let all_rules: BTreeSet<&'static str> = enabled.iter().map(|(spec, _)| spec.id).collect();
let mut states = States::default();
let mut cache: Vec<Problem> = Vec::new();
let mut out = Vec::new();
let mut disabled = DisableDirective::new(all_rules.clone());
let mut disabled_for_line = DisableDirective::new(all_rules.clone());
let mut disabled_for_next_line = DisableDirective::new(all_rules.clone());
for elem in &stream.elems {
match *elem {
Elem::Token(idx) => {
let ctx = Ctx {
stream: &stream,
idx,
};
for (spec, rule_conf) in &enabled {
if let Check::Token(check) = spec.check {
let before = cache.len();
check(rule_conf, &ctx, &mut states, &mut cache);
for problem in &mut cache[before..] {
problem.rule = Some(spec.id);
problem.level = rule_conf.level();
}
}
}
}
Elem::Comment(idx) => {
for (spec, rule_conf) in &enabled {
if let Check::Comment(check) = spec.check {
let before = cache.len();
check(rule_conf, &stream, idx, &mut cache);
for problem in &mut cache[before..] {
problem.rule = Some(spec.id);
problem.level = rule_conf.level();
}
}
}
let comment = stream.comments[idx];
let text = stream.comment_text(&comment);
disabled.process_comment(text);
if stream.comment_is_inline(&comment) {
disabled_for_line.process_line_comment(text);
} else {
disabled_for_next_line.process_line_comment(text);
}
}
Elem::Line(idx) => {
for (spec, rule_conf) in &enabled {
if let Check::Line(check) = spec.check {
let before = cache.len();
check(rule_conf, &stream, idx, &mut cache);
for problem in &mut cache[before..] {
problem.rule = Some(spec.id);
problem.level = rule_conf.level();
}
}
}
for problem in std::mem::take(&mut cache) {
if !(disabled_for_line.is_disabled(&problem) || disabled.is_disabled(&problem))
{
out.push(problem);
}
}
disabled_for_line = disabled_for_next_line;
disabled_for_next_line = DisableDirective::new(all_rules.clone());
}
}
}
out
}
pub fn lint(buffer: &str, conf: &Config, filepath: Option<&Path>) -> Vec<Problem> {
if let Some(path) = filepath
&& conf.is_file_ignored(path)
{
return Vec::new();
}
static DISABLE_FILE: OnceLock<Regex> = OnceLock::new();
let first_line = buffer
.split('\n')
.next()
.unwrap_or("")
.trim_end_matches('\r');
if regex(&DISABLE_FILE, r"^#\s*yamllint disable-file\s*$").is_match(first_line) {
return Vec::new();
}
let mut syntax_error = syntax_error(buffer);
let mut out = Vec::new();
for problem in cosmetic_problems(buffer, conf, filepath) {
if let Some(err) = &syntax_error
&& err.line <= problem.line
&& err.column <= problem.column
{
out.push(syntax_error.take().expect("checked above"));
continue;
}
out.push(problem);
}
if let Some(err) = syntax_error {
out.push(err);
}
out
}
#[cfg(test)]
mod tests;