fatou 0.6.0

A language server, formatter, and linter for Julia
//! 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::linter::diagnostic::{Diagnostic, Severity};
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(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>>,
}

/// 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 `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,
}

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,
            },
            unknown,
        )
    }

    /// 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);
    }
}