Skip to main content

ravenrs_lint/
rules.rs

1//! The rules and the syntax-tree walk that finds them.
2
3use 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/// A rule `ravenrs-lint` checks. Every rule is `deny` unless the configuration lowers it, and
14/// lowering one to `allow` needs a reason.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16#[non_exhaustive]
17pub enum Lint {
18    /// A `Color` value written in the interface (`Color::rgb`, `Color::WHITE`, `Color { .. }`).
19    /// It looks right in one theme and wrong in the others. Defining a theme (`Theme { .. }`,
20    /// `Palette { .. }` or a function returning one) is where colors belong, so it is left alone,
21    /// and so are the pixels of a picture drawn with `ImageData::from_fn`.
22    RawColor,
23    /// `.font_size(..)`: text sizes come from `TextStyle` steps. A printed report's `Style` is
24    /// measured in points and left alone.
25    RawFontSize,
26    /// `.padding`, `.spacing`, `.gap` or `Insets` with a literal that is not a multiple of 4.
27    OffGridSpacing,
28    /// A `Button` caption that does not start with a verb.
29    ButtonWithoutVerb,
30    /// An `IconButton` with no `.label(..)`: screen readers would only say the icon's name.
31    IconButtonWithoutLabel,
32    /// More than one primary `Button` in one function. `Button` is primary unless `.style(..)`
33    /// says otherwise.
34    ManyPrimaryButtons,
35    /// `State::new` inside `body` or `build`, which run on every rebuild and start it over.
36    StateInBody,
37    /// Files, processes, sleeping or blocking network calls inside `body` or `build`, which
38    /// freeze the window on every rebuild.
39    BlockingInBody,
40    /// A source file longer than `max-file-lines` (600 by default).
41    OversizedFile,
42    /// A `ravenrs-lint: allow(..)` comment that does not say why.
43    AllowWithoutReason,
44    /// A `ravenrs-lint: allow(..)` comment naming a rule that does not exist.
45    UnknownLint,
46}
47
48impl Lint {
49    /// Every rule, in the order they are documented.
50    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    /// The name used in diagnostics, configuration and `allow(..)` comments.
65    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    /// The rule called `name`, if there is one.
82    pub fn from_name(name: &str) -> Option<Lint> {
83        Lint::ALL.iter().copied().find(|lint| lint.name() == name)
84    }
85
86    /// One line on what the rule protects.
87    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/// A rule broken at one place, before the configuration and the `allow` comments apply.
105#[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    // Tests live beside the code they test, as Rust does it; only the rest has to stay short.
149    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
169/// The lines taken by test code (`#[cfg(test)]` modules, `#[test]` functions) among `items`,
170/// including inline modules nested in them.
171fn 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/// The methods called on one builder, from the expression that made it.
194#[derive(Default)]
195struct Chain {
196    methods: Vec<String>,
197    /// `Some(true)` for `.style(ButtonStyle::Primary)`, `Some(false)` for any other style.
198    primary_style: Option<bool>,
199}
200
201struct Walker<'c> {
202    config: &'c Config,
203    findings: Vec<Finding>,
204    /// Chains keyed by where the expression that starts them begins.
205    chains: HashMap<(usize, usize), Chain>,
206    /// Inside `fn body(&self)` or `fn build(&self, ..)`.
207    in_body: usize,
208    /// Closures inside the current function; they run later, not on every rebuild.
209    in_closure: usize,
210    /// Primary buttons seen in each function being walked.
211    primaries: Vec<Vec<Span>>,
212    /// Inside a theme being defined (`Theme { .. }`, `Palette { .. }`, or a function returning one),
213    /// or the pixels of an `ImageData::from_fn`, where literal colors belong.
214    in_theme: usize,
215    /// Icon buttons a `Tooltip` names, keyed like `chains`.
216    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
250/// A number written in the source, with an optional minus sign.
251fn 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        // The outermost call of a chain is visited first, so it records the whole chain.
453        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        // A printed report's `Style` is measured in points, not the interface.
468        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        // The pixels of a picture made in code are its content, not the interface's colors.
524        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        // `vec![..]`, `format!(..)` and most view macros hold expressions separated by commas.
561        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
571/// A theme or its palette, where literal colors are defined.
572fn 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
583/// Imperative verbs that start English button captions.
584const 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
792/// Portuguese nouns and adjectives that end like an infinitive.
793const 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            // Infinitives end in -ar, -er, -ir, -or or -ôr (pôr and its family).
857            ["ar", "er", "ir", "or", "ôr"].iter().any(|ending| word.ends_with(ending))
858                && !PORTUGUESE_NOT_VERBS.contains(&word)
859        }
860    }
861}
862
863/// The `ravenrs-lint: allow(..)` comments of one file.
864#[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    /// The comment has the line to itself, so it applies to the next line of code.
877    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                // The next line that is not blank and not only a comment.
924                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}