arity 0.19.0

A language server, formatter, and linter for R
//! Rendering the rule reference from rule metadata.
//!
//! [`render_rule_doc`] renders one rule's section and [`render_rules_page`]
//! assembles them all into the single `docs/src/reference/rules.md` page,
//! index included. Both are shared by the snapshot test (`tests/rule_docs.rs`)
//! and the docs generator (`examples/docgen.rs`), so the pinned docs and the
//! generated page can never diverge. Rendering runs the *real* linter on each
//! example, so the diagnostics and autofix before/after always reflect current
//! behavior.
//!
//! The page is generated whole, catalogue and all — there is no hand-maintained
//! index to fall out of step with the registry when a rule is added.

use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use crate::config::LintConfig;
use crate::linter::check::{check_description_document, check_document, check_paths_with_config};
use crate::linter::diagnostic::{Diagnostic, Fix};
use crate::linter::fix::apply_fixes;
use crate::linter::render::{OutputMode, render_findings};
use crate::linter::rules::{AnyRule, Example, rules_by_category};

/// Preamble for the generated page: the header comment warning it off
/// hand-edits, the title, and the prose that frames the catalogue.
const PAGE_PREAMBLE: &str = "\
<!-- Generated by `cargo run --example docgen`. Do not edit by hand: edit each rule's \
`description()`/`examples()` in `src/linter/rules/` and regenerate. -->

# Lint rules

`arity lint` runs a set of built-in rules over each file and reports a finding
for every match. This page is the catalogue: one section per rule, keyed by its
stable **rule ID**. That ID is what a finding reports, what `select`/`ignore`
target in the [`[lint]` table](configuration.md#lint), and what an
`# arity-lint skip` comment names (see [Directives](directives.md)).

Where a rewrite is unambiguous a rule carries an autofix. A *safe* fix (shown
below as \"After applying the fix\") is applied by `arity lint --fix`; an
*unsafe* one is applied only with `--unsafe-fixes` or as an editor code action,
so it has no \"after\" block here. A fix is a textual edit and never lays code
out, so the intended pipeline is fix-then-format.

Every example below is linted live to produce its diagnostic and fixed output,
so this page never drifts from the rules' actual behavior.
";

/// The synthetic path an example snippet is linted under — one per grammar, and
/// the real file name in both cases.
///
/// The same value must key both the lint call and the `render_findings` source
/// lookup: the linter rewrites every diagnostic's `path` to the one passed
/// here, and the pretty renderer silently degrades to a one-liner if the source
/// can't be found for that exact path.
fn example_path(rule: &AnyRule) -> PathBuf {
    match rule {
        AnyRule::R(_) => PathBuf::from("example.R"),
        AnyRule::Dcf(_) => PathBuf::from("DESCRIPTION"),
    }
}

/// The fenced-block language an example is rendered under. mdBook ships stock
/// highlight.js, which has no DCF grammar — and `yaml`, the nearest thing,
/// would highlight a lie about continuation lines.
fn example_language(rule: &AnyRule) -> &'static str {
    match rule {
        AnyRule::R(_) => "r",
        AnyRule::Dcf(_) => "text",
    }
}

/// The rule set an example snippet is linted under: the rule itself, plus
/// whatever it declares via `doc_select`.
///
/// Restricting `select` keeps an example from tripping an unrelated rule. Some
/// rules need company all the same — `outdated-suppression` can only tell a
/// stale directive from a dormant one if the suppressed rule actually ran.
///
/// Shared with `tests/rule_docs.rs`, so the pinned pages and the "every example
/// triggers its rule" check agree on what is enabled.
pub fn example_lint_config(rule: &AnyRule) -> LintConfig {
    let mut select = vec![rule.id().to_string()];
    select.extend(rule.doc_select().iter().map(|id| id.to_string()));
    LintConfig {
        select: Some(select),
        compat: rule.doc_compat(),
        ..Default::default()
    }
}

/// Lint one documented example exactly as the reference page does.
///
/// Shared with `tests/rule_docs.rs` so the "does this example still trigger?"
/// check can never drift from what the page actually renders.
pub fn lint_example(rule: &AnyRule, example: &Example) -> Vec<Diagnostic> {
    let config = example_lint_config(rule);
    let path = example_path(rule);
    if !rule.doc_package().is_empty() {
        return lint_example_in_package(rule, example, &config, &path);
    }
    match rule {
        AnyRule::R(_) => check_document(&path, example.source, &config),
        AnyRule::Dcf(_) => check_description_document(&path, example.source, &config),
    }
    .unwrap_or_default()
}

/// Lint an example inside the synthetic package its rule declares — the
/// `doc_package` path, for rules whose subject is a package-level fact and are
/// therefore silent on the single-file path.
///
/// The example is written where R would keep it (`R/example.R`, or the
/// `DESCRIPTION` itself), the whole root is linted through the cross-file
/// driver, and the surviving findings are relabelled with the synthetic path so
/// the rendered snippet still matches the source the reader is shown.
fn lint_example_in_package(
    rule: &AnyRule,
    example: &Example,
    config: &LintConfig,
    path: &Path,
) -> Vec<Diagnostic> {
    let Ok(dir) = tempfile::tempdir() else {
        return Vec::new();
    };
    let root = dir.path();
    let example_at = match rule {
        AnyRule::R(_) => root.join("R").join("example.R"),
        AnyRule::Dcf(_) => root.join("DESCRIPTION"),
    };
    let files = rule
        .doc_package()
        .iter()
        .map(|(relative, contents)| (root.join(relative), *contents))
        .chain(std::iter::once((example_at.clone(), example.source)));
    for (at, contents) in files {
        if at
            .parent()
            .is_some_and(|parent| std::fs::create_dir_all(parent).is_err())
            || std::fs::write(&at, contents).is_err()
        {
            return Vec::new();
        }
    }

    let Ok(result) = check_paths_with_config(&[root.to_path_buf()], config) else {
        return Vec::new();
    };
    result
        .reports
        .into_iter()
        .filter(|report| report.path == example_at)
        .flat_map(|report| report.diagnostics)
        .map(|mut d| {
            d.path = path.to_path_buf();
            d
        })
        .collect()
}

/// Render the whole rule reference: preamble, a per-category index, and every
/// documented rule's section, in registry order.
///
/// Rules are keyed by ID, and the heading `### `id`` gives each one the anchor
/// `#id` — the stable deep link the index (and any page linking to a rule)
/// uses.
pub fn render_rules_page() -> String {
    let mut out = String::from(PAGE_PREAMBLE);

    // A rule with no examples has nothing to show, so it is left out of both the
    // index and the body rather than listed as an empty section.
    let categories: Vec<_> = rules_by_category()
        .into_iter()
        .map(|(category, rules)| {
            let documented: Vec<_> = rules
                .into_iter()
                .filter(|rule| !rule.examples().is_empty())
                .collect();
            (category, documented)
        })
        .filter(|(_, rules)| !rules.is_empty())
        .collect();

    // The index labels its groups in bold rather than with headings: a second
    // `## Correctness` would take the `#correctness-1` anchor and split the
    // category's identity across two links.
    for (category, rules) in &categories {
        let _ = writeln!(out);
        let _ = writeln!(out, "**{}**", category.title());
        let _ = writeln!(out);
        for rule in rules {
            let id = rule.id();
            let _ = writeln!(out, "- [`{id}`](#{id})");
        }
    }

    for (category, rules) in &categories {
        let _ = writeln!(out);
        let _ = writeln!(out, "## {}", category.title());
        for rule in rules {
            let _ = writeln!(out);
            let _ = out.write_str(&render_rule_doc(rule));
        }
    }

    out
}

/// Render one rule's section of the reference page — the unit the snapshot test
/// pins and [`render_rules_page`] assembles.
pub fn render_rule_doc(rule: &AnyRule) -> String {
    let mut out = String::new();
    let id = rule.id();
    let _ = writeln!(out, "### `{id}`");

    let description = rule.description().trim();
    if !description.is_empty() {
        let _ = writeln!(out);
        let _ = writeln!(out, "{description}");
    }

    let _ = writeln!(out);
    let status = if rule.default_enabled() {
        "This rule is **enabled by default**."
    } else {
        "This rule is **disabled by default**; enable it with `select`."
    };
    let _ = writeln!(out, "{status}");

    let language = example_language(rule);
    let path = example_path(rule);

    for example in rule.examples() {
        let _ = writeln!(out);
        if !example.caption.is_empty() {
            let _ = writeln!(out, "{}", example.caption);
            let _ = writeln!(out);
        }
        fenced(&mut out, language, example.source);

        let diagnostics = lint_example(rule, example);
        let source = example.source.to_string();
        let rendered = render_findings(&diagnostics, OutputMode::Pretty, false, &|candidate| {
            (candidate == &path).then(|| source.clone())
        });
        let _ = writeln!(out);
        fenced(&mut out, "text", &rendered);

        let fixes: Vec<Fix> = diagnostics.iter().filter_map(|d| d.fix.clone()).collect();
        let after = apply_fixes(example.source, &fixes, false);
        if after.applied > 0 {
            let _ = writeln!(out);
            let _ = writeln!(out, "After applying the fix:");
            let _ = writeln!(out);
            fenced(&mut out, language, &after.output);
        }
    }

    out
}

/// Write a fenced code block, normalizing the body to end with exactly one
/// newline so the closing fence always sits on its own line (idempotence).
fn fenced(out: &mut String, lang: &str, body: &str) {
    let _ = writeln!(out, "```{lang}");
    let _ = out.write_str(body);
    if !body.ends_with('\n') {
        let _ = out.write_str("\n");
    }
    let _ = writeln!(out, "```");
}