fatou 0.10.0

A language server, formatter, and linter for Julia
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
//! Lint rule trait, registry, and per-file dispatch.
//!
//! Rules run over a file in a single shared CST traversal: each rule declares
//! the [`SyntaxKind`]s it cares about via [`Rule::interests`], and
//! [`ResolvedRules::run`] 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.
//!
//! The linter is purely *semantic*: any check the formatter's `--check` mode can
//! perform belongs to the formatter, not here (see `AGENTS.md`). Rules consume
//! the [`SemanticModel`] and the CST shape; style is never their concern.
//!
//! 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`.
//!    Build findings with [`Diagnostic::new`]: severity (like the path) is
//!    stamped by the engine — override `default_severity` instead of setting it.
//! 3. Add it to [`all_rules`] below — the single source of truth. The set of
//!    valid rule IDs ([`all_rule_ids`]) is derived from it, so there is no
//!    second list to keep in sync.

use std::path::Path;
use std::sync::Arc;

use crate::config::LintConfig;
use crate::index::PackageIndex;
use crate::julia_version::VersionRange;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::linter::include_graph::IncludeProblem;
use crate::resolve::{ModulePath, PackageSource};
use crate::semantic::SemanticModel;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};

pub mod correctness;
pub mod suspicious;

/// A documented example for a rule: a snippet of Julia that triggers the rule.
///
/// The rule reference is generated by running the real linter on `source`, so
/// the rendered diagnostics stay the single source of truth (see
/// [`crate::linter::docs`]).
pub struct Example {
    /// One-line caption rendered above the snippet (markdown). May be empty.
    pub caption: &'static str,
    /// Julia source that triggers the rule. Should end with a trailing newline.
    pub source: &'static str,
}

/// Every rule the linter knows about — the single source of truth.
pub fn all_rules() -> Vec<Box<dyn Rule>> {
    vec![
        Box::new(correctness::UnusedBinding),
        Box::new(correctness::UnusedImport),
        Box::new(correctness::DuplicateArgument),
        Box::new(correctness::UnusedArgument),
        Box::new(correctness::UndefinedName),
        Box::new(correctness::BreakOutsideLoop),
        Box::new(correctness::NotEqDefinition),
        Box::new(correctness::UnusedTypeParameter),
        Box::new(correctness::MissingIncludeFile),
        Box::new(correctness::IncludeCycle),
        Box::new(correctness::JuliaVersionCompat),
        Box::new(correctness::CallArity),
        Box::new(correctness::RedefinedConstant),
        Box::new(correctness::TypePiracy),
        Box::new(suspicious::AssignmentInCondition),
        Box::new(suspicious::NothingComparison),
        Box::new(suspicious::ConstantCondition),
        Box::new(suspicious::ModuleShadowsParent),
    ]
}

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

/// What a rule sees when it runs against one file.
pub struct RuleContext<'a> {
    pub path: Option<&'a Path>,
    pub root: &'a SyntaxNode,
    pub model: &'a SemanticModel,
    /// The name-resolution context, when the caller has one: the harvested
    /// library (Base/Core at minimum) plus the enclosing workspace package.
    /// `None` leaves resolution-dependent rules (`undefined-name`) silent.
    pub resolution: Option<ResolutionContext<'a>>,
    /// This file's include-graph problems, precomputed by the lint driver (see
    /// [`crate::linter::include_graph`]). Empty leaves the include-graph rules
    /// (`missing-include-file`, `include-cycle`) silent — the language server
    /// passes no problems and keeps publishing its own graph diagnostics.
    pub includes: &'a [IncludeProblem],
    /// The project's declared Julia support range, when known (from
    /// `[julia] version`, `Project.toml` `[compat]`, or the CLI). `None` leaves
    /// version-gated rules (`julia-version-compat`) silent. Constant per run —
    /// carried on [`ResolvedRules`] and copied here by the driver.
    pub julia_target: Option<VersionRange>,
}

/// What a resolution-dependent rule resolves free reads against: a
/// [`PackageSource`] (the CLI passes the built-in Base/Core export snapshot;
/// the language server its harvested library) and, when the file belongs to a
/// package under development, that package plus the file's host module path —
/// the same tier-2 context every LSP feature threads through
/// [`crate::resolve::Resolver::with_workspace`].
pub struct ResolutionContext<'a> {
    pub packages: &'a dyn PackageSource,
    pub workspace: Option<(Arc<PackageIndex>, ModulePath)>,
}

pub trait Rule: Send + Sync {
    /// The stable rule identifier (e.g. `unused-binding`).
    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. The default is empty — a rule with no
    /// examples is skipped by the docs generator.
    fn examples(&self) -> &'static [Example] {
        &[]
    }

    /// The Julia target range under which this rule's [`examples`](Self::examples)
    /// are linted for the reference pages. Only version-gated rules
    /// (`julia-version-compat`) need this; the default `None` matches every
    /// version-agnostic rule. All of a rule's examples share this target.
    fn example_julia_target(&self) -> Option<VersionRange> {
        None
    }

    /// The `SyntaxKind`s this rule subscribes to. During [`ResolvedRules::run`]'s 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 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);
    }
}

/// One enabled rule plus the severity this run stamps on its findings: the
/// `[lint.severity]` override when present, the rule's default otherwise.
struct ConfiguredRule {
    rule: Box<dyn Rule>,
    severity: Severity,
}

/// The set of rules enabled for a run, after applying `select`/`ignore`,
/// each carrying its resolved severity.
pub struct ResolvedRules {
    rules: Vec<ConfiguredRule>,
    /// Node-dispatch table: kind discriminant -> indices into `rules` of the
    /// subscribed rules. `SyntaxKind` is a contiguous `#[repr(u16)]`, so a
    /// flat Vec indexed by `kind as usize` beats a hash map. Built once here
    /// so [`ResolvedRules::run`]'s per-file path allocates nothing for it.
    by_kind: Vec<Vec<usize>>,
    /// Whether any rule subscribes to node dispatch at all.
    any_node_rules: bool,
    /// The declared Julia support range for this run, constant across files.
    /// Copied into each [`RuleContext`] so version-gated rules can read it.
    julia_target: Option<VersionRange>,
}

impl ResolvedRules {
    /// Build the rule set honoring `config`, alongside any `select`/`ignore`/
    /// `severity` entries that name no shipped rule.
    ///
    /// Resolution order: start with every default-enabled rule (or, when
    /// `select` is set, the listed rules), then subtract anything in `ignore`.
    /// Each surviving rule gets the `[lint.severity]` override for its ID, or
    /// its own default severity.
    ///
    /// The returned `Vec<String>` holds the unrecognized IDs (first-seen order,
    /// deduplicated) so callers can warn on a typo'd `--select`/`--ignore` or
    /// `[lint.severity]` key.
    pub fn resolve(config: &LintConfig) -> (Self, Vec<String>) {
        let all = all_rules();
        let select = config.select.as_deref();

        let mut unknown = Vec::new();
        for id in select
            .into_iter()
            .flatten()
            .chain(&config.ignore)
            .chain(config.severity.keys())
        {
            let recognized = all.iter().any(|rule| rule.id() == id);
            if !recognized && !unknown.contains(id) {
                unknown.push(id.clone());
            }
        }

        let rules: Vec<ConfiguredRule> = all
            .into_iter()
            .filter(|rule| {
                let enabled = match select {
                    Some(selected) => selected.iter().any(|id| id == rule.id()),
                    None => rule.default_enabled(),
                };
                enabled && !config.ignore.iter().any(|id| id == rule.id())
            })
            .map(|rule| {
                let severity = config
                    .severity
                    .get(rule.id())
                    .copied()
                    .unwrap_or(rule.default_severity());
                ConfiguredRule { rule, severity }
            })
            .collect();

        let mut by_kind: Vec<Vec<usize>> = vec![Vec::new(); SyntaxKind::COUNT];
        let mut any_node_rules = false;
        for (i, configured) in rules.iter().enumerate() {
            for kind in configured.rule.interests() {
                by_kind[*kind as usize].push(i);
                any_node_rules = true;
            }
        }

        (
            Self {
                rules,
                by_kind,
                any_node_rules,
                julia_target: None,
            },
            unknown,
        )
    }

    /// Set the declared Julia support range these rules check against. Off by
    /// default; the CLI sets it from `[julia] version`/`Project.toml`/the
    /// `--julia-version` flag, and the docs generator sets it per example.
    #[must_use]
    pub fn with_julia_target(mut self, target: Option<VersionRange>) -> Self {
        self.julia_target = target;
        self
    }

    /// The Julia support range these rules check against, if set.
    pub fn julia_target(&self) -> Option<VersionRange> {
        self.julia_target
    }

    /// Run every configured rule against `ctx` in one shared CST traversal.
    /// Diagnostics carry `ctx.path` and are stably sorted by `(start, end,
    /// rule)`.
    pub fn run(&self, ctx: &RuleContext<'_>) -> Vec<Diagnostic> {
        let mut all = Vec::new();

        // Single shared traversal feeding every node-shape rule, dispatched
        // off the precomputed `by_kind` table. Visits tokens too
        // (`descendants_with_tokens`) so token-level rules can subscribe to
        // e.g. `IDENT` or `COMMENT`.
        //
        // Severity is stamped centrally, right after each rule call, onto
        // whatever that call pushed: rules never choose it (see
        // [`Diagnostic::new`]).
        if self.any_node_rules {
            for el in ctx.root.descendants_with_tokens() {
                for &i in &self.by_kind[el.kind() as usize] {
                    let before = all.len();
                    self.rules[i].rule.check(&el, ctx, &mut all);
                    stamp_severity(&mut all[before..], self.rules[i].severity);
                }
            }
        }

        // Whole-file pass for model-/comment-driven rules.
        for configured in &self.rules {
            let before = all.len();
            configured.rule.check_file(ctx, &mut all);
            stamp_severity(&mut all[before..], configured.severity);
        }

        // Stamp the file path onto every finding centrally; rules leave it
        // `None`.
        if let Some(path) = ctx.path {
            for diag in &mut all {
                diag.path = Some(path.to_path_buf());
            }
        }

        all.sort_by_key(|d| (d.range.start(), d.range.end(), d.rule));
        all
    }

    pub fn is_empty(&self) -> bool {
        self.rules.is_empty()
    }
}

/// Stamp `severity` onto the findings a rule just pushed. Severity is an
/// engine concern: the config override when set, the rule's default otherwise
/// (both already folded into [`ConfiguredRule::severity`] by
/// [`ResolvedRules::resolve`]).
fn stamp_severity(diags: &mut [Diagnostic], severity: Severity) {
    for diag in diags {
        diag.severity = severity;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ids(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn resolve_flags_unknown_select_and_ignore_ids() {
        let config = LintConfig {
            select: Some(ids(&["unused-binding", "made-up-rule"])),
            ignore: ids(&["also-bogus"]),
            ..Default::default()
        };
        let (_rules, unknown) = ResolvedRules::resolve(&config);
        assert_eq!(unknown, ids(&["made-up-rule", "also-bogus"]));
    }

    #[test]
    fn resolve_reports_no_unknowns_for_valid_ids() {
        let config = LintConfig {
            ignore: ids(&["unused-import"]),
            ..Default::default()
        };
        let (_rules, unknown) = ResolvedRules::resolve(&config);
        assert!(unknown.is_empty());
    }

    #[test]
    fn resolve_dedupes_repeated_unknown_ids() {
        let config = LintConfig {
            select: Some(ids(&["typo", "typo"])),
            ignore: ids(&["typo"]),
            ..Default::default()
        };
        let (_rules, unknown) = ResolvedRules::resolve(&config);
        assert_eq!(unknown, ids(&["typo"]));
    }

    #[test]
    fn resolve_flags_unknown_severity_keys() {
        let config = LintConfig {
            severity: [("no-such-rule".to_string(), Severity::Error)].into(),
            ..Default::default()
        };
        let (_rules, unknown) = ResolvedRules::resolve(&config);
        assert_eq!(unknown, ids(&["no-such-rule"]));
    }

    #[test]
    fn resolved_severity_is_override_or_rule_default() {
        let config = LintConfig {
            severity: [("unused-binding".to_string(), Severity::Error)].into(),
            ..Default::default()
        };
        let (rules, _) = ResolvedRules::resolve(&config);
        let severity_of = |id: &str| {
            rules
                .rules
                .iter()
                .find(|c| c.rule.id() == id)
                .map(|c| c.severity)
                .unwrap()
        };
        assert_eq!(severity_of("unused-binding"), Severity::Error);
        // No override: the rule's own default wins.
        assert_eq!(severity_of("duplicate-argument"), Severity::Error);
        assert_eq!(severity_of("unused-import"), Severity::Warning);
    }
}