1use 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#[derive(Debug, Clone, Copy, Default)]
17pub struct DocInfo<'a> {
18 pub path: &'a str,
20 pub language: &'a str,
22}
23
24impl<'a> DocInfo<'a> {
25 pub fn none() -> Self {
27 Self {
28 path: "",
29 language: "",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct Span {
40 pub start: usize,
42 pub end: usize,
44}
45
46impl Span {
47 pub fn new(start: usize, end: usize) -> Self {
49 Self { start, end }
50 }
51
52 pub fn len(&self) -> usize {
54 self.end - self.start
55 }
56
57 pub fn is_empty(&self) -> bool {
59 self.start == self.end
60 }
61
62 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#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Violation {
77 pub rule_id: String,
79 pub severity: Severity,
81 pub span: Span,
83 pub message: String,
85 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
95impl 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
108pub struct Linter {
110 rules: Vec<Rule>,
111 scopes: Vec<Scope>,
112 callbacks: HashMap<String, Arc<dyn MatchCallback>>,
115 parsers: HashMap<String, Arc<dyn MatchParser>>,
118}
119
120impl Linter {
121 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 pub fn rules(&self) -> &[Rule] {
204 &self.rules
205 }
206
207 pub fn scopes(&self) -> &[Scope] {
209 &self.scopes
210 }
211
212 pub fn lint(&self, source: &str) -> Vec<Violation> {
217 self.lint_in(DocInfo::none(), source)
218 }
219
220 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 pub fn lint_segments(&self, source: &str, segments: &[(usize, Span)]) -> Vec<Violation> {
234 self.lint_segments_in(DocInfo::none(), source, segments)
235 }
236
237 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 pub fn lint_merged(&self, source: &str, segments: &[(usize, Span)]) -> Vec<Violation> {
256 self.lint_merged_in(DocInfo::none(), source, segments)
257 }
258
259 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 pub fn lint_all(&self, source: &str) -> Vec<Violation> {
278 self.lint_all_in(DocInfo::none(), source)
279 }
280
281 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 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
364struct 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 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 for raw in found {
433 if raw.start >= raw.finish {
434 continue; }
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
504pub 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 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 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 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}