Skip to main content

arity_parser/
directive.rs

1//! The `# arity…` directive grammar, shared by the formatter and the linter.
2//!
3//! A directive is an ordinary `#` comment that tells one or both tools to stand
4//! down. One grammar spans them:
5//!
6//! ```text
7//! # arity[-format|-lint] <verb> [<rule>][: <reason>]      verb: skip | off | on | skip-file
8//! ```
9//!
10//! which fills a three-by-three table of scope against tool:
11//!
12//! ```text
13//! # arity-format skip: why        # arity-lint skip <rule>: why       # arity skip: why
14//! # arity-format off … on         # arity-lint off <rule> … on        # arity off … on
15//! # arity-format skip-file: why   # arity-lint skip-file <rule>: why  # arity skip-file: why
16//! ```
17//!
18//! Only a lint directive names a rule: `# arity-format` has no lint half, and
19//! `# arity` covers every rule by construction.
20//!
21//! # Deprecated spellings
22//!
23//! `# arity-ignore <rule>: why` and `# arity-ignore-file <rule>: why` are what
24//! the linter shipped with. They still parse, as [`Verb::Skip`] and
25//! [`Verb::SkipFile`] tagged [`Spelling::Deprecated`], so a rule can find and
26//! rewrite them; nothing else should treat them differently.
27//!
28//! This module is the single source of truth for that table. It parses comment
29//! *text* — no tree, no grammar — which is what lets the R walk, the `DESCRIPTION`
30//! walk, and the formatter share it without sharing a node type.
31//!
32//! # Offsets
33//!
34//! Every range is **relative to the start of the comment text** passed in. A
35//! caller holding a token adds the token's own start to make it absolute.
36
37use rowan::{TextRange, TextSize};
38
39/// Which tool a directive addresses.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Tool {
42    /// `# arity …` — both the formatter and the linter.
43    Both,
44    /// `# arity-format …` — the formatter alone.
45    Format,
46    /// `# arity-lint …` — the linter alone.
47    Lint,
48}
49
50impl Tool {
51    /// Whether a directive with this tool suppresses lint findings.
52    pub fn affects_lint(self) -> bool {
53        matches!(self, Tool::Both | Tool::Lint)
54    }
55
56    /// Whether a directive with this tool suppresses formatting.
57    pub fn affects_format(self) -> bool {
58        matches!(self, Tool::Both | Tool::Format)
59    }
60
61    /// The canonical prefix, for a diagnostic that suggests a correction.
62    pub fn prefix(self) -> &'static str {
63        match self {
64            Tool::Both => "arity",
65            Tool::Format => "arity-format",
66            Tool::Lint => "arity-lint",
67        }
68    }
69}
70
71/// Whether a directive is written the canonical way or in one of the spellings
72/// the linter shipped with.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Spelling {
75    /// `# arity-lint skip <rule>: why` and friends.
76    Canonical,
77    /// `# arity-ignore <rule>: why` / `# arity-ignore-file <rule>: why`.
78    /// Honored exactly like the canonical form; only a rule that rewrites them
79    /// should care about the difference.
80    Deprecated,
81}
82
83/// What a directive does, and over what span.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Verb {
86    /// The next non-trivia sibling only.
87    Skip,
88    /// Everything until the matching `on`, or end of file.
89    Off,
90    /// Closes the open regions opened with the same prefix.
91    On,
92    /// The whole file.
93    SkipFile,
94}
95
96impl Verb {
97    /// The verb as written in a directive.
98    pub fn as_str(self) -> &'static str {
99        match self {
100            Verb::Skip => "skip",
101            Verb::Off => "off",
102            Verb::On => "on",
103            Verb::SkipFile => "skip-file",
104        }
105    }
106
107    fn from_word(word: &str) -> Option<Self> {
108        match word {
109            "skip" => Some(Verb::Skip),
110            "off" => Some(Verb::Off),
111            "on" => Some(Verb::On),
112            "skip-file" => Some(Verb::SkipFile),
113            _ => None,
114        }
115    }
116
117    /// Whether the author owes a reason. `on` closes a region someone else
118    /// already justified, so it owes nothing.
119    pub fn wants_reason(self) -> bool {
120        !matches!(self, Verb::On)
121    }
122}
123
124/// A rule ID as written, with the range it occupies.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct RuleRef {
127    pub id: String,
128    pub range: TextRange,
129}
130
131/// A comment that announced itself as a directive but does not parse as one.
132///
133/// Recorded rather than dropped: a directive's failure mode is that nothing
134/// happens, which is also what success looks like, so a malformed one has to be
135/// reported or it fails silently.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Malformed {
138    pub tool: Tool,
139    pub kind: MalformedKind,
140    /// The offending word, or the whole prefix when nothing followed it.
141    pub range: TextRange,
142    /// The offending word as written.
143    pub word: String,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum MalformedKind {
148    /// A word in verb position that is not one of the four verbs.
149    UnknownVerb,
150    /// A tool prefix with no verb after it at all.
151    MissingVerb,
152    /// Text after the verb that is not a `: reason`. A format directive names
153    /// no rule, and the `# arity` form covers every rule by construction, so a
154    /// word there was meant to do something it cannot do.
155    UnexpectedWord,
156    /// A verb after the deprecated `arity-ignore`, which spells its own verb —
157    /// a mix of the two spellings.
158    UnexpectedVerb,
159}
160
161/// Which lint rules a directive covers.
162///
163/// The three cases are deliberately distinct because two of them look identical
164/// in the source and behave in opposite directions: `# arity-lint skip-file:`
165/// turns the whole linter off, while a bare `# arity-lint skip-file` turns
166/// nothing off at all. Collapsing them would silently promote the inert one
167/// into a blanket.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum RuleScope {
170    /// Exactly the rule named.
171    Rule(RuleRef),
172    /// Every lint rule. Written as a `:` where the rule ID would go, or implied
173    /// by the `# arity` form, whose lint half is all-rules by construction.
174    All,
175    /// No rule named at all — inert, and reported for exactly that reason.
176    Unnamed,
177}
178
179/// A recognized directive.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Directive<'a> {
182    pub tool: Tool,
183    pub verb: Verb,
184    /// The tool prefix as written — `arity-lint`, `arity-format`, `arity`, or a
185    /// deprecated `arity-ignore`/`arity-ignore-file`. A rule that rewrites the
186    /// spelling replaces exactly these bytes and leaves the rest alone.
187    pub prefix: TextRange,
188    pub scope: RuleScope,
189    /// The text after the `:`, trimmed. `None` when there is no `:`, or nothing
190    /// but whitespace follows it.
191    pub reason: Option<&'a str>,
192    pub spelling: Spelling,
193}
194
195impl Directive<'_> {
196    /// The rule the author named, if they named one.
197    pub fn rule(&self) -> Option<&RuleRef> {
198        match &self.scope {
199            RuleScope::Rule(rule) => Some(rule),
200            _ => None,
201        }
202    }
203
204    /// Whether the rule slot means anything here. A format-only directive names
205    /// no rule because it cannot, and an `on` closes whatever is open rather
206    /// than naming anything — neither is an author who forgot.
207    pub fn has_rule_slot(&self) -> bool {
208        self.tool.affects_lint() && self.verb != Verb::On
209    }
210}
211
212/// The result of reading one comment.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum Parsed<'a> {
215    Directive(Directive<'a>),
216    Malformed(Malformed),
217}
218
219/// Read one `#` comment as a directive.
220///
221/// `None` for every comment that is not one — including a comment that merely
222/// starts with the word `arity`. A tool prefix (`arity-lint`, `arity-format`,
223/// and the deprecated `arity-ignore`/`arity-ignore-file`) is unambiguous intent,
224/// so a bad tail there yields [`Parsed::Malformed`]; a bare `arity` is only a
225/// directive when a verb follows, because prose about arity is otherwise
226/// indistinguishable from a typo'd one.
227pub fn parse(text: &str) -> Option<Parsed<'_>> {
228    let hash = text.find('#')?;
229    let (body, body_at) = trim_start_at(&text[hash + 1..], hash + 1);
230
231    // The deprecated spellings first, longest prefix first: `arity-ignore-file`
232    // before `arity-ignore`, and both before the canonical prefixes.
233    if let Some((rest, rest_at)) = strip_word(body, body_at, "arity-ignore-file") {
234        return Some(Parsed::Directive(lint_tail(
235            Verb::SkipFile,
236            rest,
237            rest_at,
238            word_range(body_at, "arity-ignore-file"),
239            Spelling::Deprecated,
240        )));
241    }
242    if let Some((rest, rest_at)) = strip_word(body, body_at, "arity-ignore") {
243        return Some(arity_ignore(
244            rest,
245            rest_at,
246            word_range(body_at, "arity-ignore"),
247        ));
248    }
249    for (prefix, tool) in [("arity-lint", Tool::Lint), ("arity-format", Tool::Format)] {
250        if let Some((rest, rest_at)) = strip_word(body, body_at, prefix) {
251            return Some(tool_prefixed(
252                tool,
253                rest,
254                rest_at,
255                word_range(body_at, prefix),
256            ));
257        }
258    }
259    if let Some((rest, rest_at)) = strip_word(body, body_at, "arity") {
260        // A bare `arity` is directive intent only when a verb follows: prose
261        // about arity is otherwise indistinguishable from a typo'd directive.
262        Verb::from_word(next_word(rest)?)?;
263        return Some(tool_prefixed(
264            Tool::Both,
265            rest,
266            rest_at,
267            word_range(body_at, "arity"),
268        ));
269    }
270    None
271}
272
273/// The deprecated `# arity-ignore …`, which spells its own verb: it *is*
274/// `arity-lint skip`, so a verb after it is a mix of the two spellings.
275fn arity_ignore(rest: &str, rest_at: usize, prefix: TextRange) -> Parsed<'_> {
276    match next_word(rest) {
277        Some(word) if Verb::from_word(word).is_some() => Parsed::Malformed(Malformed {
278            tool: Tool::Lint,
279            kind: MalformedKind::UnexpectedVerb,
280            range: TextRange::at(size(rest_at), size(word.len())),
281            word: word.to_string(),
282        }),
283        _ => Parsed::Directive(lint_tail(
284            Verb::Skip,
285            rest,
286            rest_at,
287            prefix,
288            Spelling::Deprecated,
289        )),
290    }
291}
292
293/// The tail of a lint directive: a rule slot, then an optional reason.
294///
295/// A `:` where the rule ID would go is the blanket form; nothing at all is the
296/// inert one.
297fn lint_tail(
298    verb: Verb,
299    rest: &str,
300    rest_at: usize,
301    prefix: TextRange,
302    spelling: Spelling,
303) -> Directive<'_> {
304    let scope = match parse_rule(rest, rest_at) {
305        Some(rule) => RuleScope::Rule(rule),
306        None if rest.starts_with(':') => RuleScope::All,
307        None => RuleScope::Unnamed,
308    };
309    Directive {
310        tool: Tool::Lint,
311        verb,
312        prefix,
313        scope,
314        reason: parse_reason(rest),
315        spelling,
316    }
317}
318
319/// A canonical `# arity-lint …`, `# arity-format …`, or `# arity …`: the verb is
320/// required, and only a lint directive may name a rule after it.
321fn tool_prefixed(tool: Tool, rest: &str, rest_at: usize, prefix: TextRange) -> Parsed<'_> {
322    let Some(word) = next_word(rest) else {
323        return Parsed::Malformed(Malformed {
324            tool,
325            kind: MalformedKind::MissingVerb,
326            range: prefix,
327            word: tool.prefix().to_string(),
328        });
329    };
330    let Some(verb) = Verb::from_word(word) else {
331        return Parsed::Malformed(Malformed {
332            tool,
333            kind: MalformedKind::UnknownVerb,
334            range: TextRange::at(size(rest_at), size(word.len())),
335            word: word.to_string(),
336        });
337    };
338
339    let (tail, tail_at) = trim_start_at(&rest[word.len()..], rest_at + word.len());
340
341    // A lint directive names its rule after the verb; `# arity` addresses the
342    // linter with no way to name one, so its lint half is all-rules by
343    // construction, and `# arity-format` has no lint half at all. Only the forms
344    // with no rule slot can have a word here that means nothing.
345    let takes_rule = tool == Tool::Lint && verb != Verb::On;
346    if !takes_rule && let Some(extra) = next_word(tail) {
347        return Parsed::Malformed(Malformed {
348            tool,
349            kind: MalformedKind::UnexpectedWord,
350            range: TextRange::at(size(tail_at), size(extra.len())),
351            word: extra.to_string(),
352        });
353    }
354    let scope = if takes_rule {
355        match parse_rule(tail, tail_at) {
356            Some(rule) => RuleScope::Rule(rule),
357            None if tail.starts_with(':') => RuleScope::All,
358            None => RuleScope::Unnamed,
359        }
360    } else if tool == Tool::Both {
361        RuleScope::All
362    } else {
363        RuleScope::Unnamed
364    };
365    Parsed::Directive(Directive {
366        tool,
367        verb,
368        prefix,
369        scope,
370        reason: parse_reason(tail),
371        spelling: Spelling::Canonical,
372    })
373}
374
375/// Strip `word` from the head of `body` when it stands as a whole word — the
376/// next character must end the word, so `arity-format` never matches inside
377/// `arity-formatter`. Returns the trimmed remainder and its offset.
378fn strip_word<'a>(body: &'a str, body_at: usize, word: &str) -> Option<(&'a str, usize)> {
379    let rest = body.strip_prefix(word)?;
380    match rest.chars().next() {
381        None => Some((rest, body_at + word.len())),
382        Some(c) if c.is_whitespace() || c == ':' => Some(trim_start_at(rest, body_at + word.len())),
383        Some(_) => None,
384    }
385}
386
387/// The first word of `rest`, which callers pass already trimmed so the word's
388/// offset is the caller's own `rest_at`. A word runs to the first whitespace or
389/// `:`; a `rest` that is empty or opens with `:` has none.
390fn next_word(rest: &str) -> Option<&str> {
391    let end = rest
392        .find(|c: char| c == ':' || c.is_whitespace())
393        .unwrap_or(rest.len());
394    (end > 0).then(|| &rest[..end])
395}
396
397/// The rule ID at the head of `rest`, if any.
398fn parse_rule(rest: &str, rest_at: usize) -> Option<RuleRef> {
399    let word = next_word(rest)?;
400    Some(RuleRef {
401        id: word.to_string(),
402        range: TextRange::at(size(rest_at), size(word.len())),
403    })
404}
405
406/// The reason is everything after the first `:`, trimmed.
407fn parse_reason(rest: &str) -> Option<&str> {
408    let (_, reason) = rest.split_once(':')?;
409    let trimmed = reason.trim();
410    (!trimmed.is_empty()).then_some(trimmed)
411}
412
413/// `str::trim_start`, carrying the byte offset along.
414fn trim_start_at(s: &str, offset: usize) -> (&str, usize) {
415    let trimmed = s.trim_start();
416    (trimmed, offset + (s.len() - trimmed.len()))
417}
418
419/// The range a whole-word prefix occupies, starting at `at`.
420fn word_range(at: usize, word: &str) -> TextRange {
421    TextRange::at(size(at), size(word.len()))
422}
423
424fn size(n: usize) -> TextSize {
425    TextSize::from(n as u32)
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn directive(text: &str) -> Directive<'_> {
433        match parse(text) {
434            Some(Parsed::Directive(d)) => d,
435            other => panic!("expected a directive, got {other:?}"),
436        }
437    }
438
439    fn malformed(text: &str) -> Malformed {
440        match parse(text) {
441            Some(Parsed::Malformed(m)) => m,
442            other => panic!("expected a malformed directive, got {other:?}"),
443        }
444    }
445
446    fn rule_id(text: &str) -> Option<String> {
447        directive(text).rule().map(|r| r.id.clone())
448    }
449
450    #[test]
451    fn format_column() {
452        let d = directive("# arity-format skip: hand-aligned");
453        assert_eq!((d.tool, d.verb), (Tool::Format, Verb::Skip));
454        assert_eq!(d.reason, Some("hand-aligned"));
455        assert_eq!(d.scope, RuleScope::Unnamed);
456        assert!(!d.has_rule_slot());
457
458        assert_eq!(
459            (
460                directive("# arity-format off").tool,
461                directive("# arity-format off").verb
462            ),
463            (Tool::Format, Verb::Off)
464        );
465        assert_eq!(directive("# arity-format on").verb, Verb::On);
466        assert_eq!(
467            directive("# arity-format skip-file: generated").verb,
468            Verb::SkipFile
469        );
470    }
471
472    #[test]
473    fn lint_column() {
474        let d = directive("# arity-lint skip unused-binding: documented API");
475        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
476        assert_eq!(d.rule().unwrap().id, "unused-binding");
477        assert_eq!(d.reason, Some("documented API"));
478        assert_eq!(d.spelling, Spelling::Canonical);
479
480        let d = directive("# arity-lint skip-file unused-binding: codegen");
481        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
482        assert_eq!(d.rule().unwrap().id, "unused-binding");
483
484        let d = directive("# arity-lint skip-file: noisy");
485        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
486        assert_eq!(d.scope, RuleScope::All);
487        assert_eq!(d.reason, Some("noisy"));
488    }
489
490    #[test]
491    fn the_deprecated_spellings_still_parse_and_are_tagged() {
492        let d = directive("# arity-ignore unused-binding: documented API");
493        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
494        assert_eq!(d.rule().unwrap().id, "unused-binding");
495        assert_eq!(d.reason, Some("documented API"));
496        assert_eq!(d.spelling, Spelling::Deprecated);
497
498        let d = directive("# arity-ignore-file unused-binding: codegen");
499        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
500        assert_eq!(d.rule().unwrap().id, "unused-binding");
501        assert_eq!(d.spelling, Spelling::Deprecated);
502
503        let d = directive("# arity-ignore-file: noisy");
504        assert_eq!(d.scope, RuleScope::All);
505        assert_eq!(d.spelling, Spelling::Deprecated);
506    }
507
508    #[test]
509    fn lint_regions() {
510        let d = directive("# arity-lint off unused-binding: generated block");
511        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Off));
512        assert_eq!(d.rule().unwrap().id, "unused-binding");
513        assert_eq!(d.reason, Some("generated block"));
514
515        // A `:` in rule position is the blanket region; nothing at all is inert.
516        assert_eq!(directive("# arity-lint off: noisy").scope, RuleScope::All);
517        let d = directive("# arity-lint off");
518        assert_eq!(d.verb, Verb::Off);
519        assert_eq!(d.scope, RuleScope::Unnamed);
520
521        assert_eq!(directive("# arity-lint on").verb, Verb::On);
522    }
523
524    #[test]
525    fn both_column() {
526        for (text, verb) in [
527            ("# arity skip: leave it", Verb::Skip),
528            ("# arity off", Verb::Off),
529            ("# arity on", Verb::On),
530            ("# arity skip-file: vendored", Verb::SkipFile),
531        ] {
532            let d = directive(text);
533            assert_eq!((d.tool, d.verb), (Tool::Both, verb), "{text}");
534            // The `# arity` form covers every lint rule by construction.
535            assert_eq!(d.scope, RuleScope::All, "{text}");
536        }
537    }
538
539    #[test]
540    fn a_bare_arity_directive_needs_a_verb() {
541        // Prose about arity is not a directive: a bare `arity` only counts when
542        // the next word is one of the four verbs.
543        assert_eq!(parse("# arity is great"), None);
544        assert_eq!(parse("# arity formats this badly"), None);
545        assert_eq!(parse("# arity"), None);
546        assert_eq!(parse("# arity: see below"), None);
547    }
548
549    #[test]
550    fn a_plain_comment_is_not_a_directive() {
551        assert_eq!(parse("# TODO: fix this"), None);
552        assert_eq!(parse("#"), None);
553        assert_eq!(parse(""), None);
554        // A longer word that merely starts with a prefix must not match.
555        assert_eq!(parse("# arity-formatter is the crate"), None);
556        assert_eq!(parse("# arity-ignored by everyone"), None);
557    }
558
559    #[test]
560    fn an_unknown_verb_is_reported_not_dropped() {
561        let m = malformed("# arity-format skipp: typo");
562        assert_eq!((m.tool, m.kind), (Tool::Format, MalformedKind::UnknownVerb));
563        assert_eq!(m.word, "skipp");
564    }
565
566    #[test]
567    fn a_missing_verb_is_reported() {
568        assert_eq!(malformed("# arity-format").kind, MalformedKind::MissingVerb);
569        assert_eq!(malformed("# arity-lint").kind, MalformedKind::MissingVerb);
570        assert_eq!(
571            malformed("# arity-format: because").kind,
572            MalformedKind::MissingVerb
573        );
574    }
575
576    #[test]
577    fn a_rule_id_on_a_format_directive_is_reported() {
578        let m = malformed("# arity-format skip unused-binding: no");
579        assert_eq!(m.kind, MalformedKind::UnexpectedWord);
580        assert_eq!(m.word, "unused-binding");
581        // The `both` form's lint half is all-rules by construction, so naming a
582        // rule there is ambiguous too.
583        assert_eq!(
584            malformed("# arity skip unused-binding: no").kind,
585            MalformedKind::UnexpectedWord
586        );
587        // Same seam catches prose written without a colon.
588        assert_eq!(malformed("# arity-format off do not touch").word, "do");
589    }
590
591    #[test]
592    fn a_skip_verb_after_arity_ignore_is_reported() {
593        let m = malformed("# arity-ignore skip unused-binding: mixed spellings");
594        assert_eq!(
595            (m.tool, m.kind),
596            (Tool::Lint, MalformedKind::UnexpectedVerb)
597        );
598        assert_eq!(m.word, "skip");
599        assert_eq!(
600            malformed("# arity-ignore skip-file unused-binding: x").kind,
601            MalformedKind::UnexpectedVerb
602        );
603    }
604
605    #[test]
606    fn a_reason_is_optional_and_trimmed() {
607        assert_eq!(directive("# arity-format skip").reason, None);
608        assert_eq!(directive("# arity-format skip:").reason, None);
609        assert_eq!(directive("# arity-format skip:    ").reason, None);
610        assert_eq!(
611            directive("# arity-format skip:  spaced  ").reason,
612            Some("spaced")
613        );
614        assert_eq!(directive("# arity-lint skip browser").reason, None);
615    }
616
617    #[test]
618    fn a_directive_with_no_rule_named_is_inert() {
619        // Shipped behavior: inert, and reported by `blanket-suppression`.
620        let d = directive("# arity-lint skip");
621        assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
622        assert_eq!(d.scope, RuleScope::Unnamed);
623        assert!(d.has_rule_slot());
624
625        // Bare, it is inert; with a `:` the author asked for every rule.
626        assert_eq!(
627            directive("# arity-lint skip-file").scope,
628            RuleScope::Unnamed
629        );
630        assert_eq!(
631            directive("# arity-lint skip: because").scope,
632            RuleScope::All
633        );
634        // And the same for the deprecated spellings.
635        assert_eq!(directive("# arity-ignore").scope, RuleScope::Unnamed);
636        assert_eq!(directive("# arity-ignore-file").scope, RuleScope::Unnamed);
637    }
638
639    #[test]
640    fn an_on_names_nothing_by_design() {
641        let d = directive("# arity-lint on");
642        assert_eq!(d.scope, RuleScope::Unnamed);
643        assert!(!d.has_rule_slot(), "an `on` closes whatever is open");
644    }
645
646    #[test]
647    fn a_comma_list_reads_as_one_rule_id() {
648        // Shipped behavior: the ID runs to the first whitespace, so this names
649        // `browser,` and suppresses neither rule. `misnamed-suppression` says so.
650        assert_eq!(
651            rule_id("# arity-lint skip browser, repeat: debugging").as_deref(),
652            Some("browser,")
653        );
654    }
655
656    #[test]
657    fn ranges_are_relative_to_the_comment_start() {
658        let text = "#   arity-lint  skip   unused-binding: why";
659        let d = directive(text);
660        let rule = d.rule().unwrap();
661        let start: usize = rule.range.start().into();
662        let end: usize = rule.range.end().into();
663        assert_eq!(&text[start..end], "unused-binding");
664
665        let m = malformed("#  arity-format  skipp: typo");
666        let start: usize = m.range.start().into();
667        let end: usize = m.range.end().into();
668        assert_eq!(&"#  arity-format  skipp: typo"[start..end], "skipp");
669    }
670
671    #[test]
672    fn a_missing_verb_points_at_the_prefix() {
673        let m = malformed("# arity-format");
674        let start: usize = m.range.start().into();
675        let end: usize = m.range.end().into();
676        assert_eq!(&"# arity-format"[start..end], "arity-format");
677        assert_eq!(m.word, "arity-format");
678    }
679
680    #[test]
681    fn the_prefix_range_spans_exactly_the_prefix() {
682        for text in [
683            "# arity-lint skip browser: r",
684            "#   arity-format   off",
685            "# arity-ignore-file browser: r",
686            "# arity-ignore browser: r",
687            "# arity skip: r",
688        ] {
689            let d = directive(text);
690            let start: usize = d.prefix.start().into();
691            let end: usize = d.prefix.end().into();
692            let written = &text[start..end];
693            assert!(
694                written.starts_with("arity") && !written.contains(char::is_whitespace),
695                "{text:?} -> {written:?}"
696            );
697        }
698        let d = directive("# arity-ignore-file browser: r");
699        let start: usize = d.prefix.start().into();
700        let end: usize = d.prefix.end().into();
701        assert_eq!(
702            &"# arity-ignore-file browser: r"[start..end],
703            "arity-ignore-file"
704        );
705    }
706
707    #[test]
708    fn a_verb_renders_as_written() {
709        assert_eq!(Verb::Skip.as_str(), "skip");
710        assert_eq!(Verb::SkipFile.as_str(), "skip-file");
711        assert_eq!(Verb::Off.as_str(), "off");
712        assert_eq!(Verb::On.as_str(), "on");
713    }
714
715    #[test]
716    fn tool_reports_what_it_affects() {
717        assert!(Tool::Both.affects_lint() && Tool::Both.affects_format());
718        assert!(Tool::Format.affects_format() && !Tool::Format.affects_lint());
719        assert!(Tool::Lint.affects_lint() && !Tool::Lint.affects_format());
720    }
721}