1use std::collections::{HashMap, HashSet};
4
5use proc_macro2::Span;
6use syn::punctuated::Punctuated;
7use syn::spanned::Spanned;
8use syn::visit::{self, Visit};
9use syn::{Expr, Lit};
10
11use crate::config::{Config, Language};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16#[non_exhaustive]
17pub enum Lint {
18 RawColor,
23 RawFontSize,
26 OffGridSpacing,
28 ButtonWithoutVerb,
30 IconButtonWithoutLabel,
32 ManyPrimaryButtons,
35 StateInBody,
37 BlockingInBody,
40 OversizedFile,
42 AllowWithoutReason,
44 UnknownLint,
46}
47
48impl Lint {
49 pub const ALL: &'static [Lint] = &[
51 Lint::RawColor,
52 Lint::RawFontSize,
53 Lint::OffGridSpacing,
54 Lint::ButtonWithoutVerb,
55 Lint::IconButtonWithoutLabel,
56 Lint::ManyPrimaryButtons,
57 Lint::StateInBody,
58 Lint::BlockingInBody,
59 Lint::OversizedFile,
60 Lint::AllowWithoutReason,
61 Lint::UnknownLint,
62 ];
63
64 pub fn name(self) -> &'static str {
66 match self {
67 Lint::RawColor => "raw_color",
68 Lint::RawFontSize => "raw_font_size",
69 Lint::OffGridSpacing => "off_grid_spacing",
70 Lint::ButtonWithoutVerb => "button_without_verb",
71 Lint::IconButtonWithoutLabel => "icon_button_without_label",
72 Lint::ManyPrimaryButtons => "many_primary_buttons",
73 Lint::StateInBody => "state_in_body",
74 Lint::BlockingInBody => "blocking_in_body",
75 Lint::OversizedFile => "oversized_file",
76 Lint::AllowWithoutReason => "allow_without_reason",
77 Lint::UnknownLint => "unknown_lint",
78 }
79 }
80
81 pub fn from_name(name: &str) -> Option<Lint> {
83 Lint::ALL.iter().copied().find(|lint| lint.name() == name)
84 }
85
86 pub fn summary(self) -> &'static str {
88 match self {
89 Lint::RawColor => "colors come from `Semantic` roles, so every theme stays right",
90 Lint::RawFontSize => "text sizes come from `TextStyle` steps",
91 Lint::OffGridSpacing => "padding, spacing and gaps stay on the 4px grid",
92 Lint::ButtonWithoutVerb => "button captions start with a verb",
93 Lint::IconButtonWithoutLabel => "icon buttons have a name for screen readers",
94 Lint::ManyPrimaryButtons => "one primary action per view",
95 Lint::StateInBody => "state lives in the component, not in `body`",
96 Lint::BlockingInBody => "`body` and `build` never block",
97 Lint::OversizedFile => "source files stay small enough to read",
98 Lint::AllowWithoutReason => "every exception says why",
99 Lint::UnknownLint => "exceptions name real rules",
100 }
101 }
102}
103
104#[derive(Debug)]
106pub(crate) struct Finding {
107 pub lint: Lint,
108 pub line: usize,
109 pub column: usize,
110 pub end_column: usize,
111 pub message: String,
112 pub help: String,
113}
114
115impl Finding {
116 fn at(lint: Lint, span: Span, message: impl Into<String>, help: impl Into<String>) -> Self {
117 let start = span.start();
118 let end = span.end();
119 let end_column = if end.line == start.line && end.column > start.column {
120 end.column + 1
121 } else {
122 start.column + 2
123 };
124 Finding {
125 lint,
126 line: start.line,
127 column: start.column + 1,
128 end_column,
129 message: message.into(),
130 help: help.into(),
131 }
132 }
133}
134
135pub(crate) fn check(file: &syn::File, config: &Config, lines: usize) -> Vec<Finding> {
136 let mut walker = Walker {
137 config,
138 findings: Vec::new(),
139 chains: HashMap::new(),
140 in_body: 0,
141 in_closure: 0,
142 primaries: Vec::new(),
143 in_theme: 0,
144 named: HashSet::new(),
145 };
146 walker.visit_file(file);
147 let mut findings = walker.findings;
148 let lines = lines.saturating_sub(test_lines(&file.items));
150 if lines > config.max_file_lines {
151 findings.push(Finding {
152 lint: Lint::OversizedFile,
153 line: 1,
154 column: 1,
155 end_column: 2,
156 message: format!(
157 "this file has {lines} lines besides its tests, more than the {} allowed",
158 config.max_file_lines
159 ),
160 help:
161 "split it into modules by responsibility, such as one per screen, a `model` and a `storage` \
162 module; move each `impl` with its type"
163 .into(),
164 });
165 }
166 findings
167}
168
169fn test_lines(items: &[syn::Item]) -> usize {
172 items
173 .iter()
174 .map(|item| {
175 let attrs = match item {
176 syn::Item::Mod(module) => &module.attrs,
177 syn::Item::Fn(function) => &function.attrs,
178 syn::Item::Impl(block) => &block.attrs,
179 _ => return 0,
180 };
181 if is_test_code(attrs) {
182 let span = item.span();
183 span.end().line.saturating_sub(span.start().line) + 1
184 } else if let syn::Item::Mod(syn::ItemMod { content: Some((_, inner)), .. }) = item {
185 test_lines(inner)
186 } else {
187 0
188 }
189 })
190 .sum()
191}
192
193#[derive(Default)]
195struct Chain {
196 methods: Vec<String>,
197 primary_style: Option<bool>,
199}
200
201struct Walker<'c> {
202 config: &'c Config,
203 findings: Vec<Finding>,
204 chains: HashMap<(usize, usize), Chain>,
206 in_body: usize,
208 in_closure: usize,
210 primaries: Vec<Vec<Span>>,
212 in_theme: usize,
215 named: HashSet<(usize, usize)>,
217}
218
219fn key(span: Span) -> (usize, usize) {
220 let start = span.start();
221 (start.line, start.column)
222}
223
224fn is_test_code(attrs: &[syn::Attribute]) -> bool {
225 attrs.iter().any(|attr| {
226 let path = attr.path();
227 path.is_ident("test")
228 || path.segments.last().is_some_and(|s| s.ident == "test")
229 || (path.is_ident("cfg") && attr.parse_args::<syn::Ident>().is_ok_and(|ident| ident == "test"))
230 })
231}
232
233fn segments(path: &syn::Path) -> Vec<String> {
234 path.segments.iter().map(|s| s.ident.to_string()).collect()
235}
236
237fn ends_with(path: &syn::Path, tail: &[&str]) -> bool {
238 let segments = segments(path);
239 segments.len() >= tail.len()
240 && segments[segments.len() - tail.len()..].iter().zip(tail).all(|(a, b)| a == b)
241}
242
243fn call_path(call: &syn::ExprCall) -> Option<&syn::Path> {
244 match &*call.func {
245 Expr::Path(path) => Some(&path.path),
246 _ => None,
247 }
248}
249
250fn literal_number(expr: &Expr) -> Option<f64> {
252 match expr {
253 Expr::Lit(lit) => match &lit.lit {
254 Lit::Float(value) => value.base10_parse().ok(),
255 Lit::Int(value) => value.base10_parse().ok(),
256 _ => None,
257 },
258 Expr::Unary(unary) if matches!(unary.op, syn::UnOp::Neg(_)) => {
259 literal_number(&unary.expr).map(|v| -v)
260 }
261 Expr::Paren(paren) => literal_number(&paren.expr),
262 _ => None,
263 }
264}
265
266impl Walker<'_> {
267 fn report(&mut self, lint: Lint, span: Span, message: impl Into<String>, help: impl Into<String>) {
268 self.findings.push(Finding::at(lint, span, message, help));
269 }
270
271 fn in_rebuild(&self) -> bool {
272 self.in_body > 0 && self.in_closure == 0
273 }
274
275 fn check_grid(&mut self, what: &str, args: &Punctuated<Expr, syn::Token![,]>) {
276 for arg in args {
277 let values: Vec<&Expr> = match arg {
278 Expr::Tuple(tuple) => tuple.elems.iter().collect(),
279 Expr::Array(array) => array.elems.iter().collect(),
280 other => vec![other],
281 };
282 for value in values {
283 let Some(number) = literal_number(value) else { continue };
284 if number % 4.0 != 0.0 {
285 let near = (number / 4.0).round() * 4.0;
286 self.report(
287 Lint::OffGridSpacing,
288 value.span(),
289 format!("`{what}` of {number} is off the 4px grid"),
290 format!(
291 "use a multiple of 4, such as {near}: screens pad by 32, cards by 24, stacks space by 16, \
292 related items by 8"
293 ),
294 );
295 }
296 }
297 }
298 }
299
300 fn check_caption(&mut self, caption: &syn::LitStr) {
301 let text = caption.value();
302 let Some(first) = text.split_whitespace().next() else { return };
303 let word: String = first.chars().filter(|c| c.is_alphabetic()).collect::<String>().to_lowercase();
304 if word.is_empty() || is_verb(&word, self.config) {
305 return;
306 }
307 let example = match self.config.language {
308 Language::PortugueseBrazil => "\"Salvar alterações\", \"Cadastrar membro\", \"Exportar PDF\"",
309 Language::English => "\"Save changes\", \"Add member\", \"Export PDF\"",
310 };
311 self.report(
312 Lint::ButtonWithoutVerb,
313 caption.span(),
314 format!("button caption \"{text}\" does not start with a verb"),
315 format!(
316 "say what the button does, starting with a verb: {example}. If \"{first}\" is a verb, add it \
317 to `extra-verbs` in [package.metadata.ravenrs-lint]"
318 ),
319 );
320 }
321
322 fn button(&mut self, call: &syn::ExprCall) {
323 let chain = self.chains.get(&key(call.span()));
324 let primary = match chain.and_then(|c| c.primary_style) {
325 Some(primary) => primary,
326 None => !chain.is_some_and(|c| c.methods.iter().any(|m| m == "style")),
327 };
328 if let Some(Expr::Lit(syn::ExprLit { lit: Lit::Str(caption), .. })) = call.args.first() {
329 self.check_caption(caption);
330 }
331 if !primary {
332 return;
333 }
334 let span = call.span();
335 let Some(seen) = self.primaries.last_mut() else { return };
336 seen.push(span);
337 if seen.len() > 1 {
338 let first = seen[0].start().line;
339 self.report(
340 Lint::ManyPrimaryButtons,
341 span,
342 format!("a second primary button; the first is on line {first}"),
343 "`Button` is primary unless styled: keep one primary action per view and give the others \
344 `.style(ButtonStyle::Secondary)` or `.style(ButtonStyle::Ghost)`",
345 );
346 }
347 }
348
349 fn icon_button(&mut self, call: &syn::ExprCall) {
350 let at = key(call.span());
351 let labeled = self.named.contains(&at)
352 || self.chains.get(&at).is_some_and(|c| c.methods.iter().any(|m| m == "label"));
353 if !labeled {
354 self.report(
355 Lint::IconButtonWithoutLabel,
356 call.span(),
357 "icon button without a name for screen readers",
358 "add `.label(\"...\")` with the action, such as `.label(\"Search\")`, or wrap it in a `Tooltip`; \
359 the icon's name rarely says what it does",
360 );
361 }
362 }
363
364 fn blocking(&mut self, path: &syn::Path) {
365 let segments = segments(path);
366 let what = if segments.iter().rev().skip(1).any(|s| s == "fs") {
367 Some("reads or writes files")
368 } else if ends_with(path, &["thread", "sleep"]) || ends_with(path, &["sleep"]) && segments.len() == 1
369 {
370 Some("sleeps")
371 } else if ends_with(path, &["File", "open"]) || ends_with(path, &["File", "create"]) {
372 Some("opens a file")
373 } else if ends_with(path, &["Command", "new"]) {
374 Some("starts a process")
375 } else if segments.iter().any(|s| s == "blocking") {
376 Some("waits on the network")
377 } else if ends_with(path, &["block_on"]) {
378 Some("blocks on a future")
379 } else {
380 None
381 };
382 if let Some(what) = what {
383 self.report(
384 Lint::BlockingInBody,
385 path.span(),
386 format!("`{}` {what} inside a rebuild", segments.join("::")),
387 "`body` and `build` run on every rebuild, so this freezes the window each time: load it once \
388 before the window opens or in a background task, keep the result in a `State`, and only read \
389 that state here",
390 );
391 }
392 }
393
394 fn walk_fn(
395 &mut self,
396 name: &syn::Ident,
397 sig: &syn::Signature,
398 attrs: &[syn::Attribute],
399 f: impl FnOnce(&mut Self),
400 ) {
401 if is_test_code(attrs) {
402 return;
403 }
404 let rebuild = (name == "body" || name == "build") && sig.receiver().is_some();
405 let theme = match &sig.output {
406 syn::ReturnType::Type(_, ty) => match &**ty {
407 syn::Type::Path(path) => is_theme(&path.path),
408 _ => false,
409 },
410 syn::ReturnType::Default => false,
411 };
412 let closures = std::mem::take(&mut self.in_closure);
413 self.in_body += usize::from(rebuild);
414 self.in_theme += usize::from(theme);
415 self.primaries.push(Vec::new());
416 f(self);
417 self.primaries.pop();
418 self.in_theme -= usize::from(theme);
419 self.in_body -= usize::from(rebuild);
420 self.in_closure = closures;
421 }
422}
423
424impl<'ast> Visit<'ast> for Walker<'_> {
425 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
426 if !is_test_code(&node.attrs) {
427 visit::visit_item_mod(self, node);
428 }
429 }
430
431 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
432 if !is_test_code(&node.attrs) {
433 visit::visit_item_impl(self, node);
434 }
435 }
436
437 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
438 self.walk_fn(&node.sig.ident, &node.sig, &node.attrs, |w| visit::visit_item_fn(w, node));
439 }
440
441 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
442 self.walk_fn(&node.sig.ident, &node.sig, &node.attrs, |w| visit::visit_impl_item_fn(w, node));
443 }
444
445 fn visit_expr_closure(&mut self, node: &'ast syn::ExprClosure) {
446 self.in_closure += 1;
447 visit::visit_expr_closure(self, node);
448 self.in_closure -= 1;
449 }
450
451 fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
452 let mut methods = Vec::new();
454 let mut primary_style = None;
455 let mut root: &Expr = &node.receiver;
456 methods.push(node.method.to_string());
457 if node.method == "style" {
458 primary_style = style_is_primary(node.args.first());
459 }
460 while let Expr::MethodCall(inner) = root {
461 methods.push(inner.method.to_string());
462 if inner.method == "style" && primary_style.is_none() {
463 primary_style = style_is_primary(inner.args.first());
464 }
465 root = &inner.receiver;
466 }
467 let mut report_style = false;
469 if let Expr::Call(call) = root {
470 report_style = call_path(call).is_some_and(|path| ends_with(path, &["Style", "new"]));
471 self.chains.entry(key(root.span())).or_insert(Chain { methods, primary_style });
472 }
473
474 let method = node.method.to_string();
475 match method.as_str() {
476 _ if report_style => {}
477 "font_size" => self.report(
478 Lint::RawFontSize,
479 node.method.span(),
480 "literal font size in the interface",
481 "use a `TextStyle` step, such as `.style(TextStyle::Title)`, `Body`, `Callout` or `Caption`, so \
482 sizes stay consistent and follow the theme",
483 ),
484 "padding" | "padding_x" | "padding_y" | "spacing" | "gap" | "row_spacing" | "column_spacing" => {
485 self.check_grid(&method, &node.args);
486 }
487 _ => {}
488 }
489 visit::visit_expr_method_call(self, node);
490 }
491
492 fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
493 if let Some(path) = call_path(node) {
494 if ends_with(path, &["Tooltip", "new"])
495 && let Some(mut inner) = node.args.first()
496 {
497 while let Expr::MethodCall(call) = inner {
498 inner = &call.receiver;
499 }
500 self.named.insert(key(inner.span()));
501 }
502 if ends_with(path, &["Button", "new"]) {
503 self.button(node);
504 } else if ends_with(path, &["IconButton", "new"]) {
505 self.icon_button(node);
506 } else if ends_with(path, &["Insets", "uniform"]) || ends_with(path, &["Insets", "symmetric"]) {
507 self.check_grid("Insets", &node.args);
508 }
509 if self.in_rebuild() {
510 if ends_with(path, &["State", "new"]) {
511 self.report(
512 Lint::StateInBody,
513 path.span(),
514 "`State::new` inside a rebuild",
515 "`body` runs on every rebuild, so this state starts over each time and loses what the \
516 user did: keep the `State<T>` in a field of the component and clone it here, or use \
517 `cx.use_state` in a custom `View`",
518 );
519 }
520 self.blocking(path);
521 }
522 }
523 let picture = call_path(node).is_some_and(|path| ends_with(path, &["ImageData", "from_fn"]));
525 self.in_theme += usize::from(picture);
526 visit::visit_expr_call(self, node);
527 self.in_theme -= usize::from(picture);
528 }
529
530 fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
531 let segments = segments(&node.path);
532 if self.in_theme == 0 && segments.len() >= 2 && segments[segments.len() - 2] == "Color" {
533 self.report(
534 Lint::RawColor,
535 node.span(),
536 "literal color in the interface",
537 "use a `Semantic` role, such as `.foreground(Semantic::InkMuted)`, `.background(Semantic::Control)` \
538 or `cx.theme().color(Semantic::Accent)`, so it stays right in Dark, Light and Glass",
539 );
540 }
541 visit::visit_expr_path(self, node);
542 }
543
544 fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) {
545 let theme = is_theme(&node.path);
546 self.in_theme += usize::from(theme);
547 if self.in_theme == 0 && node.path.segments.last().is_some_and(|s| s.ident == "Color") {
548 self.report(
549 Lint::RawColor,
550 node.path.span(),
551 "literal color in the interface",
552 "use a `Semantic` role, such as `.foreground(Semantic::InkMuted)`, so it stays right in every theme",
553 );
554 }
555 visit::visit_expr_struct(self, node);
556 self.in_theme -= usize::from(theme);
557 }
558
559 fn visit_macro(&mut self, node: &'ast syn::Macro) {
560 let parsed = node.parse_body_with(Punctuated::<Expr, syn::Token![,]>::parse_terminated);
562 if let Ok(exprs) = parsed {
563 for expr in &exprs {
564 self.visit_expr(expr);
565 }
566 }
567 visit::visit_macro(self, node);
568 }
569}
570
571fn is_theme(path: &syn::Path) -> bool {
573 path.segments.last().is_some_and(|s| s.ident == "Theme" || s.ident == "Palette")
574}
575
576fn style_is_primary(arg: Option<&Expr>) -> Option<bool> {
577 match arg? {
578 Expr::Path(path) => Some(path.path.segments.last().is_some_and(|s| s.ident == "Primary")),
579 _ => Some(false),
580 }
581}
582
583const ENGLISH_VERBS: &[&str] = &[
585 "accept",
586 "activate",
587 "add",
588 "allow",
589 "apply",
590 "approve",
591 "archive",
592 "ask",
593 "assign",
594 "attach",
595 "back",
596 "block",
597 "book",
598 "browse",
599 "build",
600 "buy",
601 "calculate",
602 "call",
603 "cancel",
604 "change",
605 "check",
606 "choose",
607 "clear",
608 "click",
609 "clone",
610 "close",
611 "collapse",
612 "compare",
613 "complete",
614 "configure",
615 "confirm",
616 "connect",
617 "continue",
618 "convert",
619 "copy",
620 "create",
621 "crop",
622 "cut",
623 "decline",
624 "delete",
625 "deny",
626 "deploy",
627 "describe",
628 "disable",
629 "disconnect",
630 "discard",
631 "dismiss",
632 "do",
633 "download",
634 "drag",
635 "draw",
636 "drop",
637 "duplicate",
638 "edit",
639 "email",
640 "empty",
641 "enable",
642 "end",
643 "enter",
644 "erase",
645 "exit",
646 "expand",
647 "explore",
648 "export",
649 "fetch",
650 "fill",
651 "filter",
652 "find",
653 "finish",
654 "fix",
655 "flag",
656 "follow",
657 "forget",
658 "format",
659 "forward",
660 "generate",
661 "get",
662 "give",
663 "go",
664 "grant",
665 "group",
666 "hide",
667 "highlight",
668 "import",
669 "insert",
670 "install",
671 "invite",
672 "join",
673 "keep",
674 "launch",
675 "learn",
676 "leave",
677 "like",
678 "link",
679 "list",
680 "load",
681 "lock",
682 "log",
683 "login",
684 "logout",
685 "make",
686 "manage",
687 "mark",
688 "merge",
689 "minimize",
690 "move",
691 "mute",
692 "name",
693 "new",
694 "next",
695 "notify",
696 "ok",
697 "open",
698 "order",
699 "pause",
700 "paste",
701 "pay",
702 "pick",
703 "pin",
704 "place",
705 "play",
706 "post",
707 "preview",
708 "previous",
709 "print",
710 "publish",
711 "pull",
712 "push",
713 "quit",
714 "rate",
715 "read",
716 "record",
717 "redo",
718 "refresh",
719 "register",
720 "reject",
721 "reload",
722 "remind",
723 "remove",
724 "rename",
725 "reopen",
726 "reorder",
727 "repeat",
728 "replace",
729 "reply",
730 "report",
731 "request",
732 "reset",
733 "resize",
734 "restart",
735 "restore",
736 "resume",
737 "retry",
738 "return",
739 "review",
740 "revert",
741 "rotate",
742 "run",
743 "save",
744 "scan",
745 "schedule",
746 "search",
747 "see",
748 "select",
749 "send",
750 "set",
751 "share",
752 "show",
753 "sign",
754 "skip",
755 "sort",
756 "split",
757 "start",
758 "stop",
759 "submit",
760 "subscribe",
761 "swap",
762 "switch",
763 "sync",
764 "take",
765 "tag",
766 "test",
767 "toggle",
768 "track",
769 "transfer",
770 "translate",
771 "try",
772 "turn",
773 "undo",
774 "unlock",
775 "unmute",
776 "unpin",
777 "unsubscribe",
778 "update",
779 "upgrade",
780 "upload",
781 "use",
782 "validate",
783 "verify",
784 "view",
785 "visit",
786 "vote",
787 "watch",
788 "write",
789 "zoom",
790];
791
792const PORTUGUESE_NOT_VERBS: &[&str] = &[
794 "altar",
795 "amor",
796 "ar",
797 "autor",
798 "bar",
799 "calor",
800 "celular",
801 "cor",
802 "credor",
803 "devedor",
804 "doutor",
805 "escolar",
806 "familiar",
807 "favor",
808 "flor",
809 "interior",
810 "lar",
811 "lugar",
812 "maior",
813 "mar",
814 "melhor",
815 "menor",
816 "mulher",
817 "pastor",
818 "par",
819 "pior",
820 "popular",
821 "professor",
822 "regular",
823 "senhor",
824 "setor",
825 "similar",
826 "superior",
827 "titular",
828 "valor",
829 "vapor",
830 "exterior",
831 "anterior",
832 "posterior",
833 "inferior",
834 "colher",
835 "prazer",
836 "lazer",
837 "auxiliar",
838 "militar",
839 "solar",
840 "primar",
841 "dólar",
842 "açúcar",
843 "radar",
844 "cantor",
845 "leitor",
846 "tutor",
847];
848
849fn is_verb(word: &str, config: &Config) -> bool {
850 if config.extra_verbs.iter().any(|verb| verb.to_lowercase() == word) {
851 return true;
852 }
853 match config.language {
854 Language::English => ENGLISH_VERBS.contains(&word),
855 Language::PortugueseBrazil => {
856 ["ar", "er", "ir", "or", "ôr"].iter().any(|ending| word.ends_with(ending))
858 && !PORTUGUESE_NOT_VERBS.contains(&word)
859 }
860 }
861}
862
863#[derive(Default)]
865pub(crate) struct Allowances {
866 whole_file: HashSet<Lint>,
867 by_line: HashMap<usize, HashSet<Lint>>,
868}
869
870struct AllowComment<'a> {
871 line: usize,
872 column: usize,
873 whole_file: bool,
874 names: Vec<&'a str>,
875 reason: &'a str,
876 own_line: bool,
878}
879
880const MARKER: &str = "ravenrs-lint:";
881
882fn allow_comments(source: &str) -> Vec<AllowComment<'_>> {
883 let mut found = Vec::new();
884 for (index, line) in source.lines().enumerate() {
885 let Some(comment_at) = line.find("//") else { continue };
886 let comment = &line[comment_at..];
887 let Some(marker_at) = comment.find(MARKER) else { continue };
888 let rest = comment[marker_at + MARKER.len()..].trim_start();
889 let (whole_file, rest) = if let Some(rest) = rest.strip_prefix("allow-file(") {
890 (true, rest)
891 } else if let Some(rest) = rest.strip_prefix("allow(") {
892 (false, rest)
893 } else {
894 continue;
895 };
896 let Some(close) = rest.find(')') else { continue };
897 let names = rest[..close].split(',').map(str::trim).filter(|n| !n.is_empty()).collect();
898 let reason = rest[close + 1..].trim().trim_start_matches(['-', '—', ':', '–']).trim();
899 found.push(AllowComment {
900 line: index + 1,
901 column: comment_at + marker_at + 1,
902 whole_file,
903 names,
904 reason,
905 own_line: line[..comment_at].trim().is_empty(),
906 });
907 }
908 found
909}
910
911impl Allowances {
912 pub(crate) fn read(source: &str) -> Self {
913 let lines: Vec<&str> = source.lines().collect();
914 let mut allowances = Allowances::default();
915 for comment in allow_comments(source) {
916 let lints = comment.names.iter().filter_map(|name| Lint::from_name(name));
917 if comment.whole_file {
918 allowances.whole_file.extend(lints);
919 continue;
920 }
921 let mut target = comment.line;
922 if comment.own_line {
923 target = (comment.line..lines.len())
925 .find(|&i| {
926 let text = lines[i].trim();
927 !text.is_empty() && !text.starts_with("//")
928 })
929 .map_or(comment.line, |i| i + 1);
930 }
931 allowances.by_line.entry(target).or_default().extend(lints);
932 }
933 allowances
934 }
935
936 pub(crate) fn covers(&self, lint: Lint, line: usize) -> bool {
937 self.whole_file.contains(&lint) || self.by_line.get(&line).is_some_and(|lints| lints.contains(&lint))
938 }
939}
940
941pub(crate) fn check_allow_comments(source: &str) -> Vec<Finding> {
942 let mut findings = Vec::new();
943 for comment in allow_comments(source) {
944 let at = |lint, message: String, help: &str| Finding {
945 lint,
946 line: comment.line,
947 column: comment.column,
948 end_column: comment.column + MARKER.len(),
949 message,
950 help: help.to_owned(),
951 };
952 for name in &comment.names {
953 if Lint::from_name(name).is_none() {
954 let known: Vec<&str> = Lint::ALL.iter().map(|l| l.name()).collect();
955 findings.push(at(
956 Lint::UnknownLint,
957 format!("unknown rule `{name}`"),
958 &format!("the rules are: {}", known.join(", ")),
959 ));
960 }
961 }
962 if comment.reason.is_empty() {
963 findings.push(at(
964 Lint::AllowWithoutReason,
965 "exception without a reason".into(),
966 "fix the code instead; when the exception is right, say why after the parenthesis, such as \
967 `// ravenrs-lint: allow(raw_color) the logo keeps its brand color in every theme`",
968 ));
969 }
970 }
971 findings
972}