Skip to main content

declint_core/
linter.rs

1//! The lint engine: compiled rules in, [`Violation`]s out.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use crate::callback::{
8    Callbacks, Decision, MatchCallback, MatchContext, MatchParser, RawMatch,
9};
10use crate::config::{Config, ConfigError, Matcher, Rule, Scope};
11use crate::scopes;
12use crate::Severity;
13
14/// Per-document information callbacks can see: the file's path and
15/// language id. Empty strings are fine when the caller has neither.
16#[derive(Debug, Clone, Copy, Default)]
17pub struct DocInfo<'a> {
18    /// The file's path, as shown to the user.
19    pub path: &'a str,
20    /// The document's language id.
21    pub language: &'a str,
22}
23
24impl<'a> DocInfo<'a> {
25    /// No path, no language.
26    pub fn none() -> Self {
27        Self {
28            path: "",
29            language: "",
30        }
31    }
32}
33
34/// A byte range in the linted source.
35///
36/// Unlike increparse's `Span` there is no revision — a violation is a fact
37/// about one snapshot of one file.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct Span {
40    /// Inclusive start byte offset.
41    pub start: usize,
42    /// Exclusive end byte offset.
43    pub end: usize,
44}
45
46impl Span {
47    /// Creates a span.
48    pub fn new(start: usize, end: usize) -> Self {
49        Self { start, end }
50    }
51
52    /// The span's length in bytes.
53    pub fn len(&self) -> usize {
54        self.end - self.start
55    }
56
57    /// Whether the span covers no bytes.
58    pub fn is_empty(&self) -> bool {
59        self.start == self.end
60    }
61
62    /// The span as a `Range<usize>` for slicing.
63    pub fn to_range(&self) -> std::ops::Range<usize> {
64        self.start..self.end
65    }
66}
67
68impl From<std::ops::Range<usize>> for Span {
69    fn from(range: std::ops::Range<usize>) -> Self {
70        Self::new(range.start, range.end)
71    }
72}
73
74/// One rule hit: where, how bad, and the rendered message.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Violation {
77    /// The id of the rule that fired.
78    pub rule_id: String,
79    /// How serious the hit is.
80    pub severity: Severity,
81    /// The matched byte range.
82    pub span: Span,
83    /// The rule's message template rendered for this match.
84    pub message: String,
85    /// The rendered replacement, when the rule has a `fix` template.
86    pub fix: Option<String>,
87}
88
89impl PartialOrd for Violation {
90    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
91        Some(self.cmp(other))
92    }
93}
94
95/// Ordered by position, then span end, then rule id — the deterministic
96/// output order used by every lint entry point.
97impl Ord for Violation {
98    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
99        (self.span.start, self.span.end, &self.rule_id, &self.message).cmp(&(
100            other.span.start,
101            other.span.end,
102            &other.rule_id,
103            &other.message,
104        ))
105    }
106}
107
108/// A compiled, ready-to-run rule set.
109pub struct Linter {
110    rules: Vec<Rule>,
111    scopes: Vec<Scope>,
112    /// Resolved rule callback implementations, keyed by rule id. Rules
113    /// without a callback are absent.
114    callbacks: HashMap<String, Arc<dyn MatchCallback>>,
115    /// Resolved rule parser implementations, keyed by rule id. Rules
116    /// with a regex matcher are absent.
117    parsers: HashMap<String, Arc<dyn MatchParser>>,
118}
119
120impl Linter {
121    /// Compiles a config into a linter, resolving every rule's callback
122    /// and parser against `callbacks`. Errors when a rule references a
123    /// callback or parser that is not registered — patterns themselves
124    /// were validated at config-load time.
125    pub fn new(config: Config, callbacks: &Callbacks) -> Result<Self, ConfigError> {
126        let mut resolved = HashMap::new();
127        let mut resolved_parsers = HashMap::new();
128        fn place<T>(
129            slot: Option<&Arc<T>>,
130            resolved: &mut HashMap<String, Arc<T>>,
131            rule: &Rule,
132            scope: Option<&Scope>,
133            reference: &crate::callback::CallbackRef,
134        ) -> Result<(), ConfigError>
135        where
136            T: ?Sized + 'static,
137        {
138            match slot {
139                Some(implementation) => {
140                    resolved.insert(rule.id.clone(), Arc::clone(implementation));
141                    Ok(())
142                }
143                None => match scope {
144                    Some(scope) => Err(ConfigError::new(format!(
145                        "scope '{}' rule '{}' references {} which is not registered",
146                        scope.id,
147                        rule.id,
148                        reference.describe()
149                    ))),
150                    None => Err(ConfigError::new(format!(
151                        "rule '{}' references {} which is not registered",
152                        rule.id,
153                        reference.describe()
154                    ))),
155                },
156            }
157        }
158        for rule in &config.rules {
159            if let Some(r) = &rule.callback {
160                place(callbacks.resolve(r).as_ref(), &mut resolved, rule, None, r)?;
161            }
162            if let Some(r) = &rule.parser {
163                place(
164                    callbacks.resolve_parser(r).as_ref(),
165                    &mut resolved_parsers,
166                    rule,
167                    None,
168                    r,
169                )?;
170            }
171        }
172        for scope in &config.scopes {
173            for rule in &scope.rules {
174                if let Some(r) = &rule.callback {
175                    place(
176                        callbacks.resolve(r).as_ref(),
177                        &mut resolved,
178                        rule,
179                        Some(scope),
180                        r,
181                    )?;
182                }
183                if let Some(r) = &rule.parser {
184                    place(
185                        callbacks.resolve_parser(r).as_ref(),
186                        &mut resolved_parsers,
187                        rule,
188                        Some(scope),
189                        r,
190                    )?;
191                }
192            }
193        }
194        Ok(Self {
195            rules: config.rules,
196            scopes: config.scopes,
197            callbacks: resolved,
198            parsers: resolved_parsers,
199        })
200    }
201
202    /// The global rules, in config order.
203    pub fn rules(&self) -> &[Rule] {
204        &self.rules
205    }
206
207    /// The scopes, in config order.
208    pub fn scopes(&self) -> &[Scope] {
209        &self.scopes
210    }
211
212    /// Lints one snapshot of a source file with the **global rules**
213    /// only — scoped rules are ignored. Hits are sorted by position (then
214    /// span end, then rule id) so output is deterministic regardless of
215    /// rule order. Zero-width matches are skipped.
216    pub fn lint(&self, source: &str) -> Vec<Violation> {
217        self.lint_in(DocInfo::none(), source)
218    }
219
220    /// Like [`Linter::lint`], with file path and language exposed to
221    /// callbacks.
222    pub fn lint_in(&self, info: DocInfo<'_>, source: &str) -> Vec<Violation> {
223        let mut out = Vec::new();
224        self.collect_global(info, source, &mut out);
225        crate::suppressions::apply(source, &mut out);
226        sort_violations(&mut out);
227        out
228    }
229
230    /// Lints pre-computed scope regions — the subpasses. `segments` are
231    /// `(scope index, region)` pairs, typically from [`scopes::segment_all`]
232    /// or a parse tree's scoped nodes.
233    pub fn lint_segments(&self, source: &str, segments: &[(usize, Span)]) -> Vec<Violation> {
234        self.lint_segments_in(DocInfo::none(), source, segments)
235    }
236
237    /// Like [`Linter::lint_segments`], with file path and language
238    /// exposed to callbacks.
239    pub fn lint_segments_in(
240        &self,
241        info: DocInfo<'_>,
242        source: &str,
243        segments: &[(usize, Span)],
244    ) -> Vec<Violation> {
245        let mut out = Vec::new();
246        self.collect_segments(info, source, segments, &mut out);
247        crate::suppressions::apply(source, &mut out);
248        sort_violations(&mut out);
249        out
250    }
251
252    /// Global rules plus scoped rules over `segments`, merged and sorted —
253    /// one call for consumers that already have regions (e.g. a parse
254    /// tree).
255    pub fn lint_merged(&self, source: &str, segments: &[(usize, Span)]) -> Vec<Violation> {
256        self.lint_merged_in(DocInfo::none(), source, segments)
257    }
258
259    /// Like [`Linter::lint_merged`], with file path and language exposed
260    /// to callbacks.
261    pub fn lint_merged_in(
262        &self,
263        info: DocInfo<'_>,
264        source: &str,
265        segments: &[(usize, Span)],
266    ) -> Vec<Violation> {
267        let mut out = Vec::new();
268        self.collect_global(info, source, &mut out);
269        self.collect_segments(info, source, segments, &mut out);
270        crate::suppressions::apply(source, &mut out);
271        sort_violations(&mut out);
272        out
273    }
274
275    /// Segments the source itself, then lints everything — the one-liner
276    /// for CLI use.
277    pub fn lint_all(&self, source: &str) -> Vec<Violation> {
278        self.lint_all_in(DocInfo::none(), source)
279    }
280
281    /// Like [`Linter::lint_all`], with file path and language exposed to
282    /// callbacks.
283    pub fn lint_all_in(&self, info: DocInfo<'_>, source: &str) -> Vec<Violation> {
284        let segments = scopes::segment_all(source, &self.scopes);
285        self.lint_merged_in(info, source, &segments)
286    }
287
288    /// Runs a single rule over a source snapshot — global or scoped
289    /// automatically, by where the rule lives. Unknown ids are an error.
290    /// Used by `declint test` to exercise one rule's fixtures.
291    pub fn lint_rule(
292        &self,
293        rule_id: &str,
294        info: DocInfo<'_>,
295        source: &str,
296    ) -> Result<Vec<Violation>, String> {
297        let matchers = |rule: &Rule| Matchers {
298            callback: self.callbacks.get(rule.id.as_str()).map(|a| a.as_ref()),
299            parser: self.parsers.get(rule.id.as_str()).map(|a| a.as_ref()),
300        };
301        if let Some(rule) = self.rules.iter().find(|rule| rule.id == rule_id) {
302            let mut out = Vec::new();
303            collect_rule(rule, matchers(rule), info, source, source, 0, &mut out);
304            crate::suppressions::apply(source, &mut out);
305            sort_violations(&mut out);
306            return Ok(out);
307        }
308        for scope in &self.scopes {
309            if let Some(rule) = scope.rules.iter().find(|rule| rule.id == rule_id) {
310                let mut out = Vec::new();
311                for segment in scopes::segment(source, scope) {
312                    let region = &source[segment.to_range()];
313                    collect_rule(
314                        rule,
315                        matchers(rule),
316                        info,
317                        source,
318                        region,
319                        segment.start,
320                        &mut out,
321                    );
322                }
323                crate::suppressions::apply(source, &mut out);
324                sort_violations(&mut out);
325                return Ok(out);
326            }
327        }
328        Err(format!("unknown rule `{rule_id}`"))
329    }
330
331    fn collect_global(&self, info: DocInfo<'_>, source: &str, out: &mut Vec<Violation>) {
332        for rule in &self.rules {
333            let matchers = Matchers {
334                callback: self.callbacks.get(&rule.id).map(|a| a.as_ref()),
335                parser: self.parsers.get(&rule.id).map(|a| a.as_ref()),
336            };
337            collect_rule(rule, matchers, info, source, source, 0, out);
338        }
339    }
340
341    fn collect_segments(
342        &self,
343        info: DocInfo<'_>,
344        source: &str,
345        segments: &[(usize, Span)],
346        out: &mut Vec<Violation>,
347    ) {
348        for &(scope_index, segment) in segments {
349            let Some(scope) = self.scopes.get(scope_index) else {
350                continue;
351            };
352            let region = &source[segment.to_range()];
353            for rule in &scope.rules {
354                let matchers = Matchers {
355                    callback: self.callbacks.get(&rule.id).map(|a| a.as_ref()),
356                    parser: self.parsers.get(&rule.id).map(|a| a.as_ref()),
357                };
358                collect_rule(rule, matchers, info, source, region, segment.start, out);
359            }
360        }
361    }
362}
363
364/// A rule's resolved implementations for one lint run.
365struct Matchers<'a> {
366    callback: Option<&'a dyn MatchCallback>,
367    parser: Option<&'a dyn MatchParser>,
368}
369
370impl fmt::Debug for Linter {
371    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
372        f.debug_struct("Linter")
373            .field("rules", &self.rules.len())
374            .field("scopes", &self.scopes.len())
375            .field("callbacks", &self.callbacks.len())
376            .field("parsers", &self.parsers.len())
377            .finish()
378    }
379}
380
381fn collect_rule(
382    rule: &Rule,
383    matchers: Matchers<'_>,
384    info: DocInfo<'_>,
385    source: &str,
386    text: &str,
387    offset: usize,
388    out: &mut Vec<Violation>,
389) {
390    // Find phase: every matcher produces the same relative-match shape.
391    let found: Vec<RawMatch> = match (&rule.matcher, matchers.parser) {
392        (Matcher::Regex(regex), _) => regex
393            .captures_iter(text)
394            .filter_map(|caps| {
395                let whole = caps.get(0)?;
396                if whole.is_empty() {
397                    return None;
398                }
399                let mut raw = RawMatch::new(whole.start(), whole.end());
400                let group_names: Vec<Option<&str>> = rule.capture_names();
401                for (i, group) in caps.iter().enumerate().skip(1) {
402                    let Some(group) = group else { continue };
403                    let name = group_names
404                        .get(i)
405                        .cloned()
406                        .flatten()
407                        .map_or_else(|| i.to_string(), str::to_string);
408                    raw = raw.with_capture(name, group.as_str());
409                }
410                Some(raw)
411            })
412            .collect(),
413        (Matcher::Parser, Some(parser)) => match parser.find(text, offset) {
414            Ok(found) => found,
415            Err(e) => {
416                out.push(Violation {
417                    rule_id: rule.id.clone(),
418                    severity: Severity::Error,
419                    span: Span::new(offset, offset),
420                    message: format!("rule '{}': parser error: {e}", rule.id),
421                    fix: None,
422                });
423                return;
424            }
425        },
426        (Matcher::Parser, None) => {
427            unreachable!("parser rules always resolve to a registered parser")
428        }
429    };
430
431    // Decide phase: one shared path for both matchers.
432    for raw in found {
433        if raw.start >= raw.finish {
434            continue; // zero-width matches are noise
435        }
436        let start = offset + raw.start;
437        let finish = offset + raw.finish;
438        let (severity, message) = match matchers.callback {
439            None => {
440                let match_text = source.get(start..finish).unwrap_or_default();
441                (
442                    rule.severity,
443                    rule.message
444                        .as_ref()
445                        .map_or_else(String::new, |t| t.render_with(match_text, &raw.captures)),
446                )
447            }
448            Some(callback) => {
449                let (line, col) = crate::line_col(source, start);
450                let ctx = MatchContext {
451                    path: info.path.to_string(),
452                    language: info.language.to_string(),
453                    rule_id: rule.id.clone(),
454                    start,
455                    finish,
456                    line,
457                    col,
458                    match_text: source.get(start..finish).unwrap_or_default().to_string(),
459                    captures: raw.captures.clone(),
460                };
461                match callback.evaluate(&ctx) {
462                    Err(e) => (
463                        Severity::Error,
464                        format!("rule '{}': callback error: {e}", rule.id),
465                    ),
466                    Ok(Decision::Allow) => continue,
467                    Ok(Decision::Violate { severity, message }) => {
468                        (severity.unwrap_or(rule.severity), message)
469                    }
470                    Ok(Decision::ViolateDefault) => match rule.message.as_ref() {
471                        Some(template) => (rule.severity, template.render_with(&ctx.match_text, &raw.captures)),
472                        None => (
473                            Severity::Error,
474                            format!(
475                                "rule '{}': callback violated without a default message",
476                                rule.id
477                            ),
478                        ),
479                    },
480                }
481            }
482        };
483        let fix = rule
484            .fix
485            .as_ref()
486            .map(|template| {
487                let match_text = source.get(start..finish).unwrap_or_default();
488                template.render_with(match_text, &raw.captures)
489            });
490        out.push(Violation {
491            rule_id: rule.id.clone(),
492            severity,
493            span: Span::new(start, finish),
494            message,
495            fix,
496        });
497    }
498}
499
500fn sort_violations(violations: &mut [Violation]) {
501    violations.sort();
502}
503
504/// Converts a byte offset to a 1-based `(line, column)` pair for
505/// human-readable output. The column counts characters, not bytes, so it
506/// matches what most editors show.
507pub fn line_col(source: &str, offset: usize) -> (usize, usize) {
508    let mut offset = offset.min(source.len());
509    while !source.is_char_boundary(offset) {
510        offset -= 1;
511    }
512    let head = &source[..offset];
513    let line = head.matches('\n').count() + 1;
514    let line_start = head.rfind('\n').map_or(0, |i| i + 1);
515    let col = source[line_start..offset].chars().count() + 1;
516    (line, col)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::Config;
523
524    fn linter(yaml: &str) -> Linter {
525        let config = Config::from_str(yaml).unwrap();
526        Linter::new(config, &Callbacks::new()).unwrap()
527    }
528
529    fn basic_yaml(pattern: &str, id: &str) -> String {
530        format!("version: 1\nrules:\n  - id: {id}\n    pattern: '{pattern}'\n    message: hit\n")
531    }
532
533    #[test]
534    fn basic_violation() {
535        let linter = linter(&basic_yaml("\\t+", "no-tabs"));
536        let v = linter.lint("a\tb");
537        assert_eq!(v.len(), 1);
538        assert_eq!(v[0].rule_id, "no-tabs");
539        assert_eq!(v[0].severity, Severity::Warning);
540        assert_eq!(v[0].span.to_range(), 1..2);
541        assert_eq!(v[0].message, "hit");
542    }
543
544    #[test]
545    fn violations_sorted_by_position() {
546        let yaml = "\
547version: 1
548rules:
549  - id: zzz
550    pattern: 'b'
551    message: hit
552  - id: aaa
553    pattern: 'a'
554    message: hit
555";
556        let linter = linter(yaml);
557        let v = linter.lint("abab");
558        let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
559        assert_eq!(ids, ["aaa", "zzz", "aaa", "zzz"]);
560    }
561
562    #[test]
563    fn zero_width_matches_skipped() {
564        let linter = linter(&basic_yaml("x*", "empty"));
565        assert!(linter.lint("abc").is_empty());
566    }
567
568    #[test]
569    fn template_renders_named_group() {
570        let linter = linter(&basic_yaml("(?<word>\\w+) =", "var").replace("message: hit", "message: \"rename '{word}'\""));
571        let v = linter.lint("foo = 1");
572        assert_eq!(v[0].message, "rename 'foo'");
573    }
574
575    #[test]
576    fn line_col_counts() {
577        assert_eq!(line_col("abc", 0), (1, 1));
578        assert_eq!(line_col("abc\ndef", 5), (2, 2));
579        // "héllo": h = byte 0, é = bytes 1..3 (two bytes), l = byte 3.
580        assert_eq!(line_col("héllo", 1), (1, 2));
581        assert_eq!(line_col("héllo", 3), (1, 3));
582        assert_eq!(line_col("héllo", 4), (1, 4));
583        let (line, col) = line_col("héllo", 999);
584        assert_eq!((line, col), (1, 6));
585    }
586
587    #[test]
588    fn mid_char_offset_floors() {
589        // Byte 1 is the middle of the two-byte 'é'.
590        assert_eq!(line_col("éx", 1), (1, 1));
591    }
592
593    const SCOPED: &str = "\
594version: 1
595rules:
596  - id: global
597    pattern: 'G'
598    message: global hit
599scopes:
600  - id: sh
601    start: '^```sh$'
602    end: '^```$'
603    rules:
604      - id: inner
605        pattern: 'sudo'
606        message: \"no sudo: '{match}'\"
607        severity: error
608";
609
610    const SCOPED_TEXT: &str = "G\n```sh\nsudo ls\n```\n";
611
612    #[test]
613    fn lint_is_global_only() {
614        let linter = linter(SCOPED);
615        let v = linter.lint(SCOPED_TEXT);
616        assert_eq!(v.len(), 1);
617        assert_eq!(v[0].rule_id, "global");
618        assert_eq!(v[0].span.to_range(), 0..1);
619    }
620
621    #[test]
622    fn segments_shift_spans_to_absolute_offsets() {
623        let linter = linter(SCOPED);
624        let segments = crate::scopes::segment_all(SCOPED_TEXT, linter.scopes());
625        assert_eq!(segments.len(), 1);
626        let v = linter.lint_segments(SCOPED_TEXT, &segments);
627        assert_eq!(v.len(), 1);
628        assert_eq!(v[0].rule_id, "inner");
629        assert_eq!(v[0].severity, Severity::Error);
630        // "sudo" is at absolute bytes 8..12.
631        assert_eq!(v[0].span.to_range(), 8..12);
632        assert_eq!(v[0].message, "no sudo: 'sudo'");
633    }
634
635    #[test]
636    fn merged_combines_and_sorts() {
637        let linter = linter(SCOPED);
638        let segments = crate::scopes::segment_all(SCOPED_TEXT, linter.scopes());
639        let v = linter.lint_merged(SCOPED_TEXT, &segments);
640        let ids: Vec<&str> = v.iter().map(|x| x.rule_id.as_str()).collect();
641        assert_eq!(ids, ["global", "inner"]);
642    }
643
644    #[test]
645    fn lint_all_segments_and_lints() {
646        let linter = linter(SCOPED);
647        let v = linter.lint_all(SCOPED_TEXT);
648        assert_eq!(v.len(), 2);
649    }
650
651    #[test]
652    fn unknown_scope_index_is_skipped() {
653        let linter = linter(SCOPED);
654        let segments = [(99, Span::new(0, SCOPED_TEXT.len()))];
655        assert!(linter.lint_segments(SCOPED_TEXT, &segments).is_empty());
656    }
657}