use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use crate::callback::{
Callbacks, Decision, MatchCallback, MatchContext, MatchParser, RawMatch,
};
use crate::config::{Config, ConfigError, Matcher, Rule, Scope};
use crate::scopes;
use crate::Severity;
#[derive(Debug, Clone, Copy, Default)]
pub struct DocInfo<'a> {
pub path: &'a str,
pub language: &'a str,
}
impl<'a> DocInfo<'a> {
pub fn none() -> Self {
Self {
path: "",
language: "",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub fn len(&self) -> usize {
self.end - self.start
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
pub fn to_range(&self) -> std::ops::Range<usize> {
self.start..self.end
}
}
impl From<std::ops::Range<usize>> for Span {
fn from(range: std::ops::Range<usize>) -> Self {
Self::new(range.start, range.end)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub rule_id: String,
pub severity: Severity,
pub span: Span,
pub message: String,
pub fix: Option<String>,
}
impl PartialOrd for Violation {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Violation {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(self.span.start, self.span.end, &self.rule_id, &self.message).cmp(&(
other.span.start,
other.span.end,
&other.rule_id,
&other.message,
))
}
}
pub struct Linter {
rules: Vec<Rule>,
scopes: Vec<Scope>,
callbacks: HashMap<String, Arc<dyn MatchCallback>>,
parsers: HashMap<String, Arc<dyn MatchParser>>,
}
impl Linter {
pub fn new(config: Config, callbacks: &Callbacks) -> Result<Self, ConfigError> {
let mut resolved = HashMap::new();
let mut resolved_parsers = HashMap::new();
fn place<T>(
slot: Option<&Arc<T>>,
resolved: &mut HashMap<String, Arc<T>>,
rule: &Rule,
scope: Option<&Scope>,
reference: &crate::callback::CallbackRef,
) -> Result<(), ConfigError>
where
T: ?Sized + 'static,
{
match slot {
Some(implementation) => {
resolved.insert(rule.id.clone(), Arc::clone(implementation));
Ok(())
}
None => match scope {
Some(scope) => Err(ConfigError::new(format!(
"scope '{}' rule '{}' references {} which is not registered",
scope.id,
rule.id,
reference.describe()
))),
None => Err(ConfigError::new(format!(
"rule '{}' references {} which is not registered",
rule.id,
reference.describe()
))),
},
}
}
for rule in &config.rules {
if let Some(r) = &rule.callback {
place(callbacks.resolve(r).as_ref(), &mut resolved, rule, None, r)?;
}
if let Some(r) = &rule.parser {
place(
callbacks.resolve_parser(r).as_ref(),
&mut resolved_parsers,
rule,
None,
r,
)?;
}
}
for scope in &config.scopes {
for rule in &scope.rules {
if let Some(r) = &rule.callback {
place(
callbacks.resolve(r).as_ref(),
&mut resolved,
rule,
Some(scope),
r,
)?;
}
if let Some(r) = &rule.parser {
place(
callbacks.resolve_parser(r).as_ref(),
&mut resolved_parsers,
rule,
Some(scope),
r,
)?;
}
}
}
Ok(Self {
rules: config.rules,
scopes: config.scopes,
callbacks: resolved,
parsers: resolved_parsers,
})
}
pub fn rules(&self) -> &[Rule] {
&self.rules
}
pub fn scopes(&self) -> &[Scope] {
&self.scopes
}
pub fn lint(&self, source: &str) -> Vec<Violation> {
self.lint_in(DocInfo::none(), source)
}
pub fn lint_in(&self, info: DocInfo<'_>, source: &str) -> Vec<Violation> {
let mut out = Vec::new();
self.collect_global(info, source, &mut out);
crate::suppressions::apply(source, &mut out);
sort_violations(&mut out);
out
}
pub fn lint_segments(&self, source: &str, segments: &[(usize, Span)]) -> Vec<Violation> {
self.lint_segments_in(DocInfo::none(), source, segments)
}
pub fn lint_segments_in(
&self,
info: DocInfo<'_>,
source: &str,
segments: &[(usize, Span)],
) -> Vec<Violation> {
let mut out = Vec::new();
self.collect_segments(info, source, segments, &mut out);
crate::suppressions::apply(source, &mut out);
sort_violations(&mut out);
out
}
pub fn lint_merged(&self, source: &str, segments: &[(usize, Span)]) -> Vec<Violation> {
self.lint_merged_in(DocInfo::none(), source, segments)
}
pub fn lint_merged_in(
&self,
info: DocInfo<'_>,
source: &str,
segments: &[(usize, Span)],
) -> Vec<Violation> {
let mut out = Vec::new();
self.collect_global(info, source, &mut out);
self.collect_segments(info, source, segments, &mut out);
crate::suppressions::apply(source, &mut out);
sort_violations(&mut out);
out
}
pub fn lint_all(&self, source: &str) -> Vec<Violation> {
self.lint_all_in(DocInfo::none(), source)
}
pub fn lint_all_in(&self, info: DocInfo<'_>, source: &str) -> Vec<Violation> {
let segments = scopes::segment_all(source, &self.scopes);
self.lint_merged_in(info, source, &segments)
}
pub fn lint_rule(
&self,
rule_id: &str,
info: DocInfo<'_>,
source: &str,
) -> Result<Vec<Violation>, String> {
let matchers = |rule: &Rule| Matchers {
callback: self.callbacks.get(rule.id.as_str()).map(|a| a.as_ref()),
parser: self.parsers.get(rule.id.as_str()).map(|a| a.as_ref()),
};
if let Some(rule) = self.rules.iter().find(|rule| rule.id == rule_id) {
let mut out = Vec::new();
collect_rule(rule, matchers(rule), info, source, source, 0, &mut out);
crate::suppressions::apply(source, &mut out);
sort_violations(&mut out);
return Ok(out);
}
for scope in &self.scopes {
if let Some(rule) = scope.rules.iter().find(|rule| rule.id == rule_id) {
let mut out = Vec::new();
for segment in scopes::segment(source, scope) {
let region = &source[segment.to_range()];
collect_rule(
rule,
matchers(rule),
info,
source,
region,
segment.start,
&mut out,
);
}
crate::suppressions::apply(source, &mut out);
sort_violations(&mut out);
return Ok(out);
}
}
Err(format!("unknown rule `{rule_id}`"))
}
fn collect_global(&self, info: DocInfo<'_>, source: &str, out: &mut Vec<Violation>) {
for rule in &self.rules {
let matchers = Matchers {
callback: self.callbacks.get(&rule.id).map(|a| a.as_ref()),
parser: self.parsers.get(&rule.id).map(|a| a.as_ref()),
};
collect_rule(rule, matchers, info, source, source, 0, out);
}
}
fn collect_segments(
&self,
info: DocInfo<'_>,
source: &str,
segments: &[(usize, Span)],
out: &mut Vec<Violation>,
) {
for &(scope_index, segment) in segments {
let Some(scope) = self.scopes.get(scope_index) else {
continue;
};
let region = &source[segment.to_range()];
for rule in &scope.rules {
let matchers = Matchers {
callback: self.callbacks.get(&rule.id).map(|a| a.as_ref()),
parser: self.parsers.get(&rule.id).map(|a| a.as_ref()),
};
collect_rule(rule, matchers, info, source, region, segment.start, out);
}
}
}
}
struct Matchers<'a> {
callback: Option<&'a dyn MatchCallback>,
parser: Option<&'a dyn MatchParser>,
}
impl fmt::Debug for Linter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Linter")
.field("rules", &self.rules.len())
.field("scopes", &self.scopes.len())
.field("callbacks", &self.callbacks.len())
.field("parsers", &self.parsers.len())
.finish()
}
}
fn collect_rule(
rule: &Rule,
matchers: Matchers<'_>,
info: DocInfo<'_>,
source: &str,
text: &str,
offset: usize,
out: &mut Vec<Violation>,
) {
let found: Vec<RawMatch> = match (&rule.matcher, matchers.parser) {
(Matcher::Regex(regex), _) => regex
.captures_iter(text)
.filter_map(|caps| {
let whole = caps.get(0)?;
if whole.is_empty() {
return None;
}
let mut raw = RawMatch::new(whole.start(), whole.end());
let group_names: Vec<Option<&str>> = rule.capture_names();
for (i, group) in caps.iter().enumerate().skip(1) {
let Some(group) = group else { continue };
let name = group_names
.get(i)
.cloned()
.flatten()
.map_or_else(|| i.to_string(), str::to_string);
raw = raw.with_capture(name, group.as_str());
}
Some(raw)
})
.collect(),
(Matcher::Parser, Some(parser)) => match parser.find(text, offset) {
Ok(found) => found,
Err(e) => {
out.push(Violation {
rule_id: rule.id.clone(),
severity: Severity::Error,
span: Span::new(offset, offset),
message: format!("rule '{}': parser error: {e}", rule.id),
fix: None,
});
return;
}
},
(Matcher::Parser, None) => {
unreachable!("parser rules always resolve to a registered parser")
}
};
for raw in found {
if raw.start >= raw.finish {
continue; }
let start = offset + raw.start;
let finish = offset + raw.finish;
let (severity, message) = match matchers.callback {
None => {
let match_text = source.get(start..finish).unwrap_or_default();
(
rule.severity,
rule.message
.as_ref()
.map_or_else(String::new, |t| t.render_with(match_text, &raw.captures)),
)
}
Some(callback) => {
let (line, col) = crate::line_col(source, start);
let ctx = MatchContext {
path: info.path.to_string(),
language: info.language.to_string(),
rule_id: rule.id.clone(),
start,
finish,
line,
col,
match_text: source.get(start..finish).unwrap_or_default().to_string(),
captures: raw.captures.clone(),
};
match callback.evaluate(&ctx) {
Err(e) => (
Severity::Error,
format!("rule '{}': callback error: {e}", rule.id),
),
Ok(Decision::Allow) => continue,
Ok(Decision::Violate { severity, message }) => {
(severity.unwrap_or(rule.severity), message)
}
Ok(Decision::ViolateDefault) => match rule.message.as_ref() {
Some(template) => (rule.severity, template.render_with(&ctx.match_text, &raw.captures)),
None => (
Severity::Error,
format!(
"rule '{}': callback violated without a default message",
rule.id
),
),
},
}
}
};
let fix = rule
.fix
.as_ref()
.map(|template| {
let match_text = source.get(start..finish).unwrap_or_default();
template.render_with(match_text, &raw.captures)
});
out.push(Violation {
rule_id: rule.id.clone(),
severity,
span: Span::new(start, finish),
message,
fix,
});
}
}
fn sort_violations(violations: &mut [Violation]) {
violations.sort();
}
pub fn line_col(source: &str, offset: usize) -> (usize, usize) {
let mut offset = offset.min(source.len());
while !source.is_char_boundary(offset) {
offset -= 1;
}
let head = &source[..offset];
let line = head.matches('\n').count() + 1;
let line_start = head.rfind('\n').map_or(0, |i| i + 1);
let col = source[line_start..offset].chars().count() + 1;
(line, col)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Config;
fn linter(yaml: &str) -> Linter {
let config = Config::from_str(yaml).unwrap();
Linter::new(config, &Callbacks::new()).unwrap()
}
fn basic_yaml(pattern: &str, id: &str) -> String {
format!("version: 1\nrules:\n - id: {id}\n pattern: '{pattern}'\n message: hit\n")
}
#[test]
fn basic_violation() {
let linter = linter(&basic_yaml("\\t+", "no-tabs"));
let v = linter.lint("a\tb");
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule_id, "no-tabs");
assert_eq!(v[0].severity, Severity::Warning);
assert_eq!(v[0].span.to_range(), 1..2);
assert_eq!(v[0].message, "hit");
}
#[test]
fn violations_sorted_by_position() {
let yaml = "\
version: 1
rules:
- id: zzz
pattern: 'b'
message: hit
- id: aaa
pattern: 'a'
message: hit
";
let linter = linter(yaml);
let v = linter.lint("abab");
let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
assert_eq!(ids, ["aaa", "zzz", "aaa", "zzz"]);
}
#[test]
fn zero_width_matches_skipped() {
let linter = linter(&basic_yaml("x*", "empty"));
assert!(linter.lint("abc").is_empty());
}
#[test]
fn template_renders_named_group() {
let linter = linter(&basic_yaml("(?<word>\\w+) =", "var").replace("message: hit", "message: \"rename '{word}'\""));
let v = linter.lint("foo = 1");
assert_eq!(v[0].message, "rename 'foo'");
}
#[test]
fn line_col_counts() {
assert_eq!(line_col("abc", 0), (1, 1));
assert_eq!(line_col("abc\ndef", 5), (2, 2));
assert_eq!(line_col("héllo", 1), (1, 2));
assert_eq!(line_col("héllo", 3), (1, 3));
assert_eq!(line_col("héllo", 4), (1, 4));
let (line, col) = line_col("héllo", 999);
assert_eq!((line, col), (1, 6));
}
#[test]
fn mid_char_offset_floors() {
assert_eq!(line_col("éx", 1), (1, 1));
}
const SCOPED: &str = "\
version: 1
rules:
- id: global
pattern: 'G'
message: global hit
scopes:
- id: sh
start: '^```sh$'
end: '^```$'
rules:
- id: inner
pattern: 'sudo'
message: \"no sudo: '{match}'\"
severity: error
";
const SCOPED_TEXT: &str = "G\n```sh\nsudo ls\n```\n";
#[test]
fn lint_is_global_only() {
let linter = linter(SCOPED);
let v = linter.lint(SCOPED_TEXT);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule_id, "global");
assert_eq!(v[0].span.to_range(), 0..1);
}
#[test]
fn segments_shift_spans_to_absolute_offsets() {
let linter = linter(SCOPED);
let segments = crate::scopes::segment_all(SCOPED_TEXT, linter.scopes());
assert_eq!(segments.len(), 1);
let v = linter.lint_segments(SCOPED_TEXT, &segments);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule_id, "inner");
assert_eq!(v[0].severity, Severity::Error);
assert_eq!(v[0].span.to_range(), 8..12);
assert_eq!(v[0].message, "no sudo: 'sudo'");
}
#[test]
fn merged_combines_and_sorts() {
let linter = linter(SCOPED);
let segments = crate::scopes::segment_all(SCOPED_TEXT, linter.scopes());
let v = linter.lint_merged(SCOPED_TEXT, &segments);
let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
assert_eq!(ids, ["global", "inner"]);
}
#[test]
fn lint_all_segments_and_lints() {
let linter = linter(SCOPED);
let v = linter.lint_all(SCOPED_TEXT);
assert_eq!(v.len(), 2);
}
#[test]
fn unknown_scope_index_is_skipped() {
let linter = linter(SCOPED);
let segments = [(99, Span::new(0, SCOPED_TEXT.len()))];
assert!(linter.lint_segments(SCOPED_TEXT, &segments).is_empty());
}
}