arity 0.17.0

A language server, formatter, and linter for R
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
//! Lint rule trait, registry, and per-file dispatch.
//!
//! Rules are run over a file in a single shared CST traversal: each rule
//! declares the [`SyntaxKind`]s it cares about via [`Rule::interests`], and
//! [`run_rules`] walks the tree once, calling [`Rule::check`] on every element
//! whose kind a rule subscribed to. Rules that work off the whole file rather
//! than node shape (semantic-model queries, comment directives) leave
//! `interests` empty and override [`Rule::check_file`], which runs once per file
//! after the walk.
//!
//! New rules:
//! 1. Create a module under `src/linter/rules/<category>/<id>.rs`.
//! 2. Define a unit `pub struct` that implements [`Rule`] — subscribe to node
//!    kinds via `interests` + `check`, or do a whole-file pass via `check_file`.
//! 3. Add it to its category's list in [`rules_by_category`] below — the single
//!    source of truth. Both the registry ([`all_rules`], and from it the set of
//!    valid rule IDs, [`all_rule_ids`]) and the generated rule reference are
//!    derived from it, so there is no second list to keep in sync.

use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::OnceLock;

use rowan::ast::AstNode as _;

use crate::ast::{BinaryExpr, CallExpr};
use crate::config::{CompatConfig, CompatVersion, LintConfig, RulesConfig};
use crate::project::description::DescriptionCompat;
use crate::project::{ExternalResolution, FileScope};
use crate::rindex::provider::CompositeProvider;
use crate::semantic::{FileControlFlow, PackageOrigin, SemanticModel, SymbolProvider};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};

use super::diagnostic::{Diagnostic, Severity};
use super::suppression::{DirectiveUsage, SuppressionMap};

pub mod correctness;
pub mod documentation;
pub mod matchers;
pub mod meta;
pub mod performance;
pub mod readability;
pub mod regex;
pub mod roxygen;
pub mod suspicious;

/// The catalogue grouping a rule is listed under in the generated rule
/// reference (`docs/src/reference/rules.md`).
///
/// The grouping lives on the registry rather than on [`Rule`]: it is a property
/// of the catalogue, not of the check, and keeping it here means a rule's
/// category is stated exactly once, next to the rule itself in
/// [`rules_by_category`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleCategory {
    Correctness,
    Suspicious,
    Readability,
    Performance,
    Documentation,
    Meta,
}

impl RuleCategory {
    /// The section heading this category is rendered under.
    pub fn title(self) -> &'static str {
        match self {
            Self::Correctness => "Correctness",
            Self::Suspicious => "Suspicious",
            Self::Readability => "Readability",
            Self::Performance => "Performance",
            Self::Documentation => "Documentation",
            Self::Meta => "Meta",
        }
    }
}

/// All rules currently shipped, grouped into the categories the reference page
/// is organized by — the single source of truth. [`all_rules`] flattens this,
/// so the registry order and the catalogue order are one list.
pub fn rules_by_category() -> Vec<(RuleCategory, Vec<Box<dyn Rule>>)> {
    vec![
        (RuleCategory::Correctness, correctness_rules()),
        (RuleCategory::Suspicious, suspicious_rules()),
        (RuleCategory::Readability, readability_rules()),
        (RuleCategory::Performance, performance_rules()),
        (RuleCategory::Documentation, documentation_rules()),
        (RuleCategory::Meta, meta_rules()),
    ]
}

/// All rules currently shipped, in registry order.
pub fn all_rules() -> Vec<Box<dyn Rule>> {
    rules_by_category()
        .into_iter()
        .flat_map(|(_, rules)| rules)
        .collect()
}

fn correctness_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(correctness::UndefinedSymbol),
        Box::new(correctness::UnusedBinding),
        Box::new(correctness::DuplicateFormal),
        Box::new(correctness::DuplicatedArguments),
        Box::new(correctness::EqualsNa),
        Box::new(correctness::VectorLogic),
        Box::new(correctness::UnreachableCode),
        Box::new(correctness::IsNumeric),
        Box::new(correctness::IfAlwaysTrue),
        Box::new(correctness::EmptyAssignment),
        Box::new(correctness::DownloadFile),
        Box::new(correctness::InternalFunction),
        Box::new(correctness::RCompat),
    ]
}

fn suspicious_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(suspicious::AssignmentInCondition),
        Box::new(suspicious::ImplicitAssignment),
        Box::new(suspicious::Browser),
        Box::new(suspicious::ShadowedBuiltin),
        Box::new(suspicious::RedundantEquals),
        Box::new(suspicious::RedundantIfelse),
        Box::new(suspicious::Repeat),
        Box::new(suspicious::UndesirableFunction),
        Box::new(suspicious::ForLoopIndex),
        Box::new(suspicious::ForLoopDupIndex),
        Box::new(suspicious::UnusedFunction),
        Box::new(suspicious::DuplicatedFunctionDefinition),
    ]
}

fn readability_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(readability::TrueFalseSymbol),
        Box::new(readability::ComparisonNegation),
        Box::new(readability::OuterNegation),
        Box::new(readability::StringBoundary),
        Box::new(readability::UnnecessaryNesting),
    ]
}

fn performance_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(performance::AnyIsNa),
        Box::new(performance::AnyDuplicated),
        Box::new(performance::Coalesce),
        Box::new(performance::Crossprod),
        Box::new(performance::Lengths),
        Box::new(performance::Nzchar),
        Box::new(performance::Seq),
        Box::new(performance::ClassEquals),
        Box::new(performance::FixedRegex),
        Box::new(performance::Sort),
    ]
}

fn documentation_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(documentation::RoxygenUnknownTag),
        Box::new(documentation::RoxygenTitle),
        Box::new(documentation::RoxygenReturn),
        Box::new(documentation::RoxygenParam),
        Box::new(documentation::RoxygenExamples),
        Box::new(documentation::Roxygen2Compat),
    ]
}

fn meta_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(meta::MisnamedSuppression),
        Box::new(meta::BlanketSuppression),
        Box::new(meta::UnexplainedSuppression),
        Box::new(meta::OutdatedSuppression),
    ]
}

/// Every shipped rule's ID, derived from [`all_rules`] so the two never drift.
/// Used to validate `LintConfig::select` / `ignore`.
pub fn all_rule_ids() -> Vec<&'static str> {
    all_rules().iter().map(|r| r.id()).collect()
}

/// Whether `id` is a rule arity ships. The `O(1)` membership oracle over
/// [`all_rule_ids`], which instantiates the whole registry on every call.
pub fn is_known_rule(id: &str) -> bool {
    static IDS: OnceLock<HashSet<&'static str>> = OnceLock::new();
    IDS.get_or_init(|| all_rule_ids().into_iter().collect())
        .contains(id)
}

/// A documented example for a rule: a snippet of R that triggers the rule.
///
/// The rule reference is generated by running the real linter on `source`, so
/// the "after" state of an autofix is *derived* (by applying the rule's safe
/// fixes) rather than stored — the snippet stays the single source of truth.
pub struct Example {
    /// One-line caption rendered above the snippet (markdown). May be empty.
    pub caption: &'static str,
    /// R source that triggers the rule. Should end with a trailing newline.
    pub source: &'static str,
}

pub trait Rule: Send + Sync {
    fn id(&self) -> &'static str;
    fn default_severity(&self) -> Severity {
        Severity::Warning
    }
    fn default_enabled(&self) -> bool {
        true
    }

    /// One-paragraph (markdown) description of what the rule flags and why,
    /// used to generate the rule reference. Empty means "not yet documented".
    fn description(&self) -> &'static str {
        ""
    }

    /// Worked examples for the rule reference. Each `source` is linted live and
    /// rendered with its diagnostics (and autofix before/after). The default is
    /// empty — a rule with no examples is skipped by the docs generator.
    fn examples(&self) -> &'static [Example] {
        &[]
    }

    /// The `SyntaxKind`s this rule subscribes to. During [`run_rules`]' single
    /// shared traversal, [`Rule::check`] is invoked once for every element whose
    /// kind appears here. The default (`&[]`) opts out of node dispatch entirely
    /// — appropriate for rules that work off the whole file via
    /// [`Rule::check_file`].
    fn interests(&self) -> &'static [SyntaxKind] {
        &[]
    }

    /// Per-element callback, invoked for each CST element (node *or* token) whose
    /// kind is in [`Rule::interests`]. Node-shape rules unwrap `el.as_node()`;
    /// token rules unwrap `el.as_token()`. Push findings onto `sink`.
    fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
        let _ = (el, ctx, sink);
    }

    /// Whole-file pass, run once after the shared traversal. For rules driven by
    /// the semantic model, cross-file scope, or comment directives rather than
    /// node shape. The default is a no-op.
    fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
        let _ = (ctx, sink);
    }

    /// Post-suppression pass, run once after the surviving findings have been
    /// filtered through the file's `# arity-ignore` directives. `used` records
    /// which directives actually matched a finding.
    ///
    /// Separate from [`Rule::check_file`] because its input is a *driver* fact —
    /// which suppressions fired — that does not exist until filtering has run.
    /// `outdated-suppression` is the only implementor; the default is a no-op.
    fn check_suppressions(
        &self,
        ctx: &RuleContext<'_>,
        used: &DirectiveUsage,
        sink: &mut Vec<Diagnostic>,
    ) {
        let _ = (ctx, used, sink);
    }

    /// Rule IDs that must also be enabled when this rule's [`Rule::examples`]
    /// are rendered and tested. The docs renderer restricts `select` to the rule
    /// itself so an example cannot trip an unrelated rule; this is the escape
    /// hatch for a rule whose subject *is* another rule's presence in the run
    /// (`outdated-suppression` needs the suppressed rule to have run in order to
    /// know its directive matched nothing). The default is none.
    fn doc_select(&self) -> &'static [&'static str] {
        &[]
    }

    /// The `[compat]` floors this rule's [`Rule::examples`] are linted under.
    /// The default run declares none — which silences the version-aware rules
    /// (`r-compat`, `roxygen2-compat`), so those override this to give their
    /// examples a floor to violate.
    fn doc_compat(&self) -> CompatConfig {
        CompatConfig::default()
    }
}

/// The rule IDs running in a pass, after `select`/`ignore`. Lets a rule tell
/// "this rule ran and found nothing" from "this rule never ran" — the
/// distinction between a stale suppression and a dormant one.
#[derive(Debug, Clone, Default)]
pub struct EnabledRules(Vec<&'static str>);

impl EnabledRules {
    pub fn contains(&self, id: &str) -> bool {
        self.0.contains(&id)
    }
}

pub struct RuleContext<'a> {
    pub path: &'a Path,
    pub root: &'a SyntaxNode,
    pub model: &'a SemanticModel,
    /// The per-file control-flow graph (one region per function body plus the
    /// file top-level). Feeds reachability-sensitive rules (`unreachable-code`).
    pub cfg: &'a FileControlFlow,
    pub symbols: &'a dyn SymbolProvider,
    /// Cross-file visibility for this file, when linting a multi-file project.
    /// `None` for single-file runs (the LSP per-document path, one-shot checks).
    pub project: Option<&'a FileScope<'a>>,
    /// Salsa-resolved external-symbol verdict for this file, when available (the
    /// cross-file lint path). Carries the backdated set of free-read names that
    /// resolve to no attached package, so `undefined-symbol` consumes a memoized
    /// result instead of re-running masking on every keystroke. `None` on the
    /// single-file paths, where the rule falls back to [`RuleContext::symbols`].
    pub resolution: Option<&'a ExternalResolution>,
    /// Per-rule option tables from `[lint.rules.<id>]`, resolved once per run and
    /// carried on [`ResolvedRules`]. Rules that take no options ignore this.
    pub config: &'a RulesConfig,
    /// The file's parsed `# arity-ignore` directives. Built once per file and
    /// used by [`run_rules`] to drop suppressed findings; the `meta/` rules read
    /// it to lint the directives themselves.
    pub suppressions: &'a SuppressionMap,
    /// The rule IDs running in this pass. A directive naming a rule that did not
    /// run is dormant, not stale.
    pub enabled_rules: &'a EnabledRules,
    /// Lazily-resolved enclosing package name — see [`RuleContext::own_package`].
    /// Private and empty at construction: resolving it touches disk, so the cost
    /// is paid only by the rules that ask, on the files where they match.
    own_package: OnceLock<Option<String>>,
    /// The configured `[compat]` floors (empty when the project sets none),
    /// resolved once per run and carried on [`ResolvedRules`]. Consult
    /// [`RuleContext::r_compat_floor`]/[`RuleContext::roxygen2_compat_floor`],
    /// which layer the per-file `DESCRIPTION` derivation underneath.
    pub compat: &'a CompatConfig,
    /// Lazily-derived `DESCRIPTION` compat facts for this file's package — the
    /// fallback under the configured floors. Same lazy-disk discipline as
    /// [`RuleContext::own_package`]: only the version-aware rules pay the walk.
    description_compat: OnceLock<DescriptionCompat>,
}

impl RuleContext<'_> {
    /// The name of the R package this file belongs to, from the `Package` field
    /// of the DESCRIPTION at the enclosing package root. `None` for a loose
    /// script, a directory that is not a package, or an unreadable DESCRIPTION.
    ///
    /// Resolved lazily and memoized for the file: the walk plus the read touch
    /// disk, and the only consumer (`internal-function`) needs it solely on the
    /// rare files that actually contain a `:::`, so the default path — every
    /// other file, every keystroke in the LSP — pays nothing.
    ///
    /// This is the seam for "is this the package's *own* internals?", which is
    /// a different question from cross-file visibility ([`RuleContext::project`]
    /// answers that, and is `None` on the single-file paths).
    pub fn own_package(&self) -> Option<&str> {
        self.own_package
            .get_or_init(|| crate::project::description::package_name_for_file(self.path))
            .as_deref()
    }

    /// The minimum supported R version this file targets, or `None` when no
    /// floor is declared anywhere — the version-aware rules must then stay
    /// silent. Resolution order: the configured `[compat] r` wins; otherwise
    /// the enclosing package's `Depends: R (>= …)` (lazily resolved and
    /// memoized, like [`RuleContext::own_package`]).
    pub fn r_compat_floor(&self) -> Option<CompatVersion> {
        self.compat
            .r_version()
            .or_else(|| self.description_compat().r.clone())
    }

    /// The roxygen2 version this file's documentation targets, or `None` when
    /// undeclared (rules stay silent). Resolution order: the configured
    /// `[compat] roxygen2` wins; otherwise the enclosing package's
    /// `Config/roxygen2/version`, then its legacy `RoxygenNote`.
    pub fn roxygen2_compat_floor(&self) -> Option<CompatVersion> {
        self.compat
            .roxygen2_version()
            .or_else(|| self.description_compat().roxygen2.clone())
    }

    fn description_compat(&self) -> &DescriptionCompat {
        self.description_compat
            .get_or_init(|| crate::project::description::description_compat_for_file(self.path))
    }

    /// Whether `call`'s callee is confirmed to invoke a base-R function: a
    /// simple name that is (a) exported by one of R's default packages, (b) not
    /// shadowed by a local binding, and (c) not masked by an attached
    /// non-default package. Computed/qualified callees (`pkg::f(...)`,
    /// `x$f(...)`, `(g())(...)`) and anything we can't confirm return `false`,
    /// keeping callers conservative — no rewrite when unsure (Tenets 3/5).
    ///
    /// This is the Phase 2 namespace-confirmation gate: a call-rewrite rule
    /// matches the shape, then asks this before rewriting a bare name.
    pub fn resolves_to_base(&self, call: &CallExpr) -> bool {
        let Some(name) = matchers::callee_name(call) else {
            return false;
        };
        if !self.symbols.is_base(&name) {
            return false;
        }
        // A namespace-qualified callee (`pkg::f(...)`) is not a bare-name base
        // call: `callee_token` unwraps `pkg::f(...)` to the bare `f`, so guard
        // against it explicitly.
        if is_namespace_qualified(call) {
            return false;
        }
        // The callee read sits in `idents` at the callee token's range; if it
        // resolves to a local binding, the base name is shadowed locally. This
        // is the same `resolve_local` pairing `shadowed-builtin` uses, keyed off
        // the call we already hold.
        if let Some(callee) = call.callee_token()
            && self.is_locally_shadowed(callee.text_range())
        {
            return false;
        }
        // Not masked by an attached non-default package.
        origin_is_default(self.symbols.origin(&name, self.model.loaded_packages()))
    }

    /// Whether the identifier read at `range` resolves to a local binding — the
    /// name is redefined in this file rather than referring to the package
    /// function of the same name. The shadow half of [`resolves_to_base`],
    /// shared with rules that match names arity cannot attribute to a package
    /// (e.g. user-configured `undesirable-function` entries) and so can only
    /// apply this weaker gate.
    ///
    /// [`resolves_to_base`]: RuleContext::resolves_to_base
    pub fn is_locally_shadowed(&self, range: rowan::TextRange) -> bool {
        self.model
            .idents()
            .iter()
            .any(|i| i.range == range && self.model.resolve_local(i).is_some())
    }

    /// Whether a bare value read (an `IDENT` token used as a value, e.g. a
    /// function passed as an argument: `sapply(x, length)`) is confirmed to be
    /// base R: exported by a default package, not shadowed by a local binding,
    /// and not masked by an attached non-default package. The value-position
    /// counterpart of [`RuleContext::resolves_to_base`], sharing its
    /// conservative stance — anything unconfirmed returns `false`.
    pub fn read_resolves_to_base(&self, token: &SyntaxToken) -> bool {
        let name = token.text();
        if !self.symbols.is_base(name) {
            return false;
        }
        if self.is_locally_shadowed(token.text_range()) {
            return false;
        }
        origin_is_default(self.symbols.origin(name, self.model.loaded_packages()))
    }
}

/// Whether `call` is the call form of a namespace access (`pkg::f(...)` /
/// `pkg:::f(...)`) — i.e. its `CALL_EXPR` is the RHS of a `::`/`:::` operator.
fn is_namespace_qualified(call: &CallExpr) -> bool {
    let Some(callee) = call.callee_token() else {
        return false;
    };
    // `pkg::fn(...)` parses with the call wrapping a `pkg::fn` `BINARY_EXPR`
    // callee, so the callee token (`callee_token` unwraps it to the bare `fn`)
    // sits under that namespace-access binary.
    callee
        .parent()
        .and_then(BinaryExpr::cast)
        .and_then(|bin| bin.namespace_access())
        .is_some_and(|ns| ns.name_token.text_range() == callee.text_range())
}

/// Whether a resolved origin's effective package (the last/masking one under R's
/// lookup rules) is one of R's default packages.
fn origin_is_default(origin: PackageOrigin) -> bool {
    let pkg = match &origin {
        PackageOrigin::Resolved(pkg) => Some(pkg.as_str()),
        PackageOrigin::Ambiguous(pkgs) => pkgs.last().map(|p| p.as_str()),
        PackageOrigin::Unknown => None,
    };
    pkg.is_some_and(|p| crate::semantic::symbols::default_packages().contains(&p))
}

/// Configured set of rules for a single linting run, plus the derived dispatch
/// state that only depends on the rule set: the node-dispatch table and each
/// rule's stamped severity. Both are computed once here (in [`with_config`], via
/// [`resolve`]) rather than rebuilt per file in [`run_rules`], so reusing one
/// `ResolvedRules` across many files — the CLI batch pass, and the LSP lint
/// worker, which caches it across keystrokes — pays that cost only once.
///
/// It also carries the run's `[lint.rules.<id>]` tables, for the same reason:
/// they are per-run config, so rules read them off [`RuleContext::config`]
/// without widening [`run_rules`].
///
/// [`with_config`]: ResolvedRules::with_config
/// [`resolve`]: ResolvedRules::resolve
pub struct ResolvedRules {
    pub rules: Vec<Box<dyn Rule>>,
    /// Node-dispatch table: `kind as usize` -> indices into `rules` of the rules
    /// that subscribed to that kind via [`Rule::interests`]. `SyntaxKind` is a
    /// contiguous `#[repr(u16)]`, so a flat Vec indexed by kind beats a hash map.
    by_kind: Vec<Vec<usize>>,
    /// Whether any rule subscribed to a node kind at all — lets [`run_rules`]
    /// skip the whole-tree traversal when every rule is `check_file`-only.
    any_node_rules: bool,
    /// Each rule ID's [`Rule::default_severity`], so the severity-stamping pass
    /// is an `O(1)` lookup keyed by the finding's rule ID.
    severities: HashMap<&'static str, Severity>,
    /// The chosen rule IDs, handed to rules via [`RuleContext::enabled_rules`].
    enabled: EnabledRules,
    /// The `[lint.rules.<id>]` tables, handed to every rule via
    /// [`RuleContext::config`]. Lives here rather than as a [`run_rules`]
    /// parameter because it is per-*run* config, exactly like the rest of this
    /// struct's derived state — so the hot per-file path carries it for free.
    rules_config: RulesConfig,
    /// The run's `[compat]` floors (mirrored onto `LintConfig` at config parse
    /// time), handed to rules via [`RuleContext::compat`] — same per-run
    /// rationale as `rules_config`.
    compat: CompatConfig,
}

impl ResolvedRules {
    /// Build the derived dispatch state (`by_kind`, `severities`) for a chosen
    /// rule set. The single place that knows how a rule set maps to dispatch.
    fn with_config(
        rules: Vec<Box<dyn Rule>>,
        rules_config: RulesConfig,
        compat: CompatConfig,
    ) -> Self {
        let mut by_kind: Vec<Vec<usize>> = vec![Vec::new(); SyntaxKind::COUNT];
        let mut any_node_rules = false;
        for (i, rule) in rules.iter().enumerate() {
            for kind in rule.interests() {
                by_kind[*kind as usize].push(i);
                any_node_rules = true;
            }
        }
        let severities = rules
            .iter()
            .map(|r| (r.id(), r.default_severity()))
            .collect();
        let enabled = EnabledRules(rules.iter().map(|r| r.id()).collect());
        Self {
            rules,
            by_kind,
            any_node_rules,
            severities,
            enabled,
            rules_config,
            compat,
        }
    }

    /// The rule IDs in this set.
    pub fn enabled(&self) -> &EnabledRules {
        &self.enabled
    }

    /// Build the rule set honoring `select` / `ignore` from `LintConfig`.
    ///
    /// Resolution order:
    /// 1. Start with all rules whose `default_enabled()` is `true`, unless
    ///    `select` is set (then start with the listed rules instead).
    /// 2. Subtract anything in `ignore`.
    /// 3. Unknown rule IDs in `select` or `ignore` are returned via the second
    ///    element of the tuple so the caller can surface them.
    ///
    /// `config.rules` (the `[lint.rules.<id>]` tables) is carried through onto
    /// the result, reaching rules via [`RuleContext::config`]. Unknown *rule
    /// tables* are rejected earlier, when the config is parsed — unlike unknown
    /// IDs in `select`/`ignore`, which are data and so surface here.
    pub fn resolve(config: &LintConfig) -> (Self, Vec<String>) {
        let select = config.select.as_deref();
        let ignore = &config.ignore;
        // Instantiate the registry once and derive the known-ID set from it —
        // rather than calling `all_rule_ids()` (a second `all_rules()`).
        let all = all_rules();
        let mut unknown = Vec::new();
        for id in select.iter().flat_map(|v| v.iter()).chain(ignore.iter()) {
            if !all.iter().any(|r| r.id() == id.as_str()) {
                unknown.push(id.clone());
            }
        }
        let mut chosen: Vec<Box<dyn Rule>> = match select {
            Some(picks) => all
                .into_iter()
                .filter(|r| picks.iter().any(|p| p == r.id()))
                .collect(),
            None => all.into_iter().filter(|r| r.default_enabled()).collect(),
        };
        chosen.retain(|r| !ignore.iter().any(|i| i == r.id()));
        (
            Self::with_config(chosen, config.rules.clone(), config.compat.clone()),
            unknown,
        )
    }

    pub fn default_set() -> Self {
        let (set, _) = Self::resolve(&LintConfig::default());
        set
    }
}

/// Run every configured rule against a single file's CST + model, dropping the
/// findings the file's `# arity-ignore` directives suppress. Diagnostics are
/// stably sorted by `(start, end, rule)` before returning.
///
/// Suppression is filtered *here*, not by the caller, for two reasons: the
/// directive list has to reach rules on [`RuleContext`], and the post-suppression
/// pass ([`Rule::check_suppressions`]) needs the *result* of filtering — which
/// directives fired — a fact that does not exist any earlier.
///
/// The dispatch table (`resolved.by_kind`) and severity map are precomputed on
/// `resolved`, so this is on the hot path only for the per-file traversal and
/// the rules' own work, not for rebuilding the rule-set-derived state.
#[allow(clippy::too_many_arguments)]
pub fn run_rules(
    resolved: &ResolvedRules,
    path: &Path,
    root: &SyntaxNode,
    model: &SemanticModel,
    cfg: &FileControlFlow,
    symbols: &dyn SymbolProvider,
    project: Option<&FileScope<'_>>,
    resolution: Option<&ExternalResolution>,
) -> Vec<Diagnostic> {
    let suppressions = SuppressionMap::build(root);
    let ctx = RuleContext {
        path,
        root,
        model,
        cfg,
        symbols,
        project,
        resolution,
        config: &resolved.rules_config,
        suppressions: &suppressions,
        enabled_rules: &resolved.enabled,
        own_package: OnceLock::new(),
        compat: &resolved.compat,
        description_compat: OnceLock::new(),
    };
    let rules = &resolved.rules;
    let mut all = Vec::new();

    // Single shared traversal feeding every node-shape rule. Visits tokens too
    // (`descendants_with_tokens`) so token-level rules can subscribe to e.g.
    // `IDENT` or `COMMENT`.
    if resolved.any_node_rules {
        for el in root.descendants_with_tokens() {
            for &i in &resolved.by_kind[el.kind() as usize] {
                rules[i].check(&el, &ctx, &mut all);
            }
        }
    }

    // Whole-file pass for model-/comment-driven rules.
    for rule in rules {
        rule.check_file(&ctx, &mut all);
    }

    // Drop the suppressed findings, recording which directives did the work.
    let used = suppressions.filter(&mut all);

    // Post-suppression pass. Its own findings are suppressible too, but against
    // the *frozen* usage record — a directive that only ever silenced an
    // `outdated-suppression` finding is not thereby "used".
    let mut post = Vec::new();
    for rule in rules {
        rule.check_suppressions(&ctx, &used, &mut post);
    }
    if !post.is_empty() {
        post.retain(|d| !suppressions.is_suppressed(d.rule, d.range));
        all.append(&mut post);
    }

    // Stamp each finding's severity from its rule's `default_severity()`. Rules
    // build findings with a placeholder severity (`Default::default()`); the
    // authoritative value lives on the rule, so overriding `default_severity()`
    // actually takes effect here (and is the natural seam for a future per-rule
    // severity config override). Keyed by rule ID against the parallel `rules`
    // /`severities` vecs — a whole-file pass may interleave findings from
    // several rules, so post-hoc lookup is simpler than tracking emit order.
    for d in &mut all {
        if let Some(&sev) = resolved.severities.get(d.rule) {
            d.severity = sev;
        }
    }

    all.sort_by(|a, b| {
        (u32::from(a.range.start()), u32::from(a.range.end()), a.rule).cmp(&(
            u32::from(b.range.start()),
            u32::from(b.range.end()),
            b.rule,
        ))
    });
    all
}

/// Provide a sane default symbol provider: base R only, with no installed-
/// package index. Behaves exactly like the historical `StaticBaseR` for files
/// that don't attach non-default packages.
pub fn default_symbol_provider() -> CompositeProvider {
    CompositeProvider::base_only()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::linter::diagnostic::ViolationData;

    /// A rule that subscribes to every `CALL_EXPR` and emits a finding carrying
    /// the *placeholder* severity (`Default::default()` == `Warning`). Its
    /// `default_severity` is overridden to `Error`, so a run that respects the
    /// override must stamp `Error` — proving `default_severity` is live, not the
    /// dead trait method it used to be.
    struct FakeError;
    impl Rule for FakeError {
        fn id(&self) -> &'static str {
            "fake-error"
        }
        fn default_severity(&self) -> Severity {
            Severity::Error
        }
        fn interests(&self) -> &'static [SyntaxKind] {
            &[SyntaxKind::CALL_EXPR]
        }
        fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
            sink.push(Diagnostic {
                rule: "fake-error",
                severity: Default::default(),
                path: Default::default(),
                range: el.text_range(),
                message: ViolationData::new("fake-error", "boom"),
                fix: None,
            });
        }
    }

    #[test]
    fn run_rules_stamps_default_severity() {
        let root = crate::parser::parse("f(1)").cst;
        let model = SemanticModel::build(&root);
        let cfg = FileControlFlow::build(&root);
        let symbols = crate::semantic::StaticBaseR::new();
        let resolved = ResolvedRules::with_config(
            vec![Box::new(FakeError)],
            RulesConfig::default(),
            CompatConfig::default(),
        );
        let diags = run_rules(
            &resolved,
            Path::new("test.R"),
            &root,
            &model,
            &cfg,
            &symbols,
            None,
            None,
        );
        assert_eq!(diags.len(), 1);
        // Emitted with the `Warning` placeholder; the override stamps `Error`.
        assert_eq!(diags[0].severity, Severity::Error);
    }

    /// Suppression filtering lives in `run_rules`, not in `check.rs` — the rules
    /// need the directive list on `RuleContext`, and `outdated-suppression`
    /// needs the *result* of filtering.
    #[test]
    fn run_rules_filters_suppressed_findings() {
        let root = crate::parser::parse("# arity-ignore fake-error: quiet\nf(1)\n").cst;
        let model = SemanticModel::build(&root);
        let cfg = FileControlFlow::build(&root);
        let symbols = crate::semantic::StaticBaseR::new();
        let resolved = ResolvedRules::with_config(
            vec![Box::new(FakeError)],
            RulesConfig::default(),
            CompatConfig::default(),
        );
        let diags = run_rules(
            &resolved,
            Path::new("test.R"),
            &root,
            &model,
            &cfg,
            &symbols,
            None,
            None,
        );
        assert!(diags.is_empty(), "expected no findings, got {diags:?}");
    }

    /// The rule set reaches rules through the context, so a post-suppression
    /// pass can tell "this rule found nothing" from "this rule never ran".
    #[test]
    fn enabled_rules_reflects_the_resolved_set() {
        let resolved = ResolvedRules::with_config(
            vec![Box::new(FakeError)],
            RulesConfig::default(),
            CompatConfig::default(),
        );
        assert!(resolved.enabled().contains("fake-error"));
        assert!(!resolved.enabled().contains("unused-binding"));
    }

    /// `resolves_to_base` for the first `CallExpr` in `src`, over the base-only
    /// `StaticBaseR` provider (the single-file / LSP path).
    fn resolves(src: &str) -> bool {
        let root = crate::parser::parse(src).cst;
        let model = SemanticModel::build(&root);
        let cfg = FileControlFlow::build(&root);
        let symbols = crate::semantic::StaticBaseR::new();
        let ctx = RuleContext {
            path: Path::new("test.R"),
            root: &root,
            model: &model,
            cfg: &cfg,
            symbols: &symbols,
            project: None,
            resolution: None,
            config: &RulesConfig::default(),
            suppressions: &SuppressionMap::default(),
            enabled_rules: &EnabledRules::default(),
            own_package: OnceLock::new(),
            compat: &CompatConfig::default(),
            description_compat: OnceLock::new(),
        };
        let call = root
            .descendants()
            .find_map(CallExpr::cast)
            .expect("a call in the source");
        ctx.resolves_to_base(&call)
    }

    #[test]
    fn confirms_unshadowed_base_call() {
        assert!(resolves("c(1, 2)"));
        assert!(resolves("f <- function() sum(a)"));
    }

    #[test]
    fn rejects_local_value_shadow() {
        // The first call is `c(2, 3)`; the local `c <- 1` shadows base `c`.
        assert!(!resolves("c <- 1\nc(2, 3)"));
    }

    #[test]
    fn rejects_function_redefinition() {
        assert!(!resolves("any <- function(x) x\nany(z)"));
    }

    #[test]
    fn rejects_nested_scope_shadow() {
        assert!(!resolves("f <- function() {\n  sum <- 1\n  sum(a)\n}"));
    }

    #[test]
    fn rejects_non_base_name() {
        assert!(!resolves("frobnicate(1)"));
    }

    #[test]
    fn rejects_qualified_callee() {
        assert!(!resolves("dplyr::filter(x)"));
    }

    #[test]
    fn rejects_computed_callee() {
        assert!(!resolves("(g())(1)"));
    }
}