fatou 0.5.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 [`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.
//!
//! 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`.
//! 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 crate::linter::diagnostic::{Diagnostic, Severity};
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(suspicious::AssignmentInCondition),
    ]
}

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

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

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 [`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 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);
    }
}

/// The set of rules enabled for a run, after applying `select`/`ignore`.
pub struct ResolvedRules {
    rules: Vec<Box<dyn Rule>>,
}

impl ResolvedRules {
    /// Build the rule set honoring `select` / `ignore`.
    ///
    /// Resolution order: start with every default-enabled rule (or, when
    /// `select` is set, the listed rules), then subtract anything in `ignore`.
    pub fn resolve(select: Option<&[String]>, ignore: &[String]) -> Self {
        let rules = all_rules()
            .into_iter()
            .filter(|rule| {
                let enabled = match select {
                    Some(selected) => selected.iter().any(|id| id == rule.id()),
                    None => rule.default_enabled(),
                };
                enabled && !ignore.iter().any(|id| id == rule.id())
            })
            .collect();
        Self { rules }
    }

    /// 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> {
        run_rules(&self.rules, ctx)
    }

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

/// Run `rules` against one file's CST + model. See [`ResolvedRules::run`].
fn run_rules(rules: &[Box<dyn Rule>], ctx: &RuleContext<'_>) -> Vec<Diagnostic> {
    let mut all = Vec::new();

    // Node-dispatch table: kind discriminant -> indices of subscribed rules.
    // `SyntaxKind` is a contiguous `#[repr(u16)]`, so a flat Vec indexed by
    // `kind as usize` beats a hash map.
    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;
        }
    }

    // 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 any_node_rules {
        for el in ctx.root.descendants_with_tokens() {
            for &i in &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);
    }

    // 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(|a, b| (a.start, a.end, &a.rule).cmp(&(b.start, b.end, &b.rule)));
    all
}