mirror of
https://github.com/astral-sh/ruff
synced 2026-01-20 21:10:48 -05:00
## Summary As the title says, this PR removes the `Message::to_rule` method by replacing related uses of `Rule` with `NoqaCode` (or the rule's name in the case of the cache). Where it seemed a `Rule` was really needed, we convert back to the `Rule` by parsing either the rule name (with `str::parse`) or the `NoqaCode` (with `Rule::from_code`). I thought this was kind of like cheating and that it might not resolve this part of Micha's [comment](https://github.com/astral-sh/ruff/pull/18391#issuecomment-2933764275): > because we can't add Rule to Diagnostic or **have it anywhere in our shared rendering logic** but after looking again, the only remaining `Rule` conversion in rendering code is for the SARIF output format. The other two non-test `Rule` conversions are for caching and writing a fix summary, which I don't think fall into the shared rendering logic. That leaves the SARIF format as the only real problem, but maybe we can delay that for now. The motivation here is that we won't be able to store a `Rule` on the new `Diagnostic` type, but we should be able to store a `NoqaCode`, likely as a string. ## Test Plan Existing tests ## [Benchmarks](https://codspeed.io/astral-sh/ruff/branches/brent%2Fremove-to-rule) Almost no perf regression, only -1% on `linter/default-rules[large/dataset.py]`. --------- Co-authored-by: Micha Reiser <micha@reiser.io>
229 lines
7.1 KiB
Rust
229 lines
7.1 KiB
Rust
//! Generate Markdown documentation for applicable rules.
|
|
|
|
use std::collections::HashSet;
|
|
use std::fmt::Write as _;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::Result;
|
|
use itertools::Itertools;
|
|
use regex::{Captures, Regex};
|
|
use strum::IntoEnumIterator;
|
|
|
|
use ruff_linter::FixAvailability;
|
|
use ruff_linter::registry::{Linter, Rule, RuleNamespace};
|
|
use ruff_options_metadata::{OptionEntry, OptionsMetadata};
|
|
use ruff_workspace::options::Options;
|
|
|
|
use crate::ROOT_DIR;
|
|
|
|
#[derive(clap::Args)]
|
|
pub(crate) struct Args {
|
|
/// Write the generated docs to stdout (rather than to the filesystem).
|
|
#[arg(long)]
|
|
pub(crate) dry_run: bool,
|
|
}
|
|
|
|
pub(crate) fn main(args: &Args) -> Result<()> {
|
|
for rule in Rule::iter() {
|
|
if let Some(explanation) = rule.explanation() {
|
|
let mut output = String::new();
|
|
|
|
let _ = writeln!(&mut output, "# {} ({})", rule.name(), rule.noqa_code());
|
|
|
|
let (linter, _) = Linter::parse_code(&rule.noqa_code().to_string()).unwrap();
|
|
if linter.url().is_some() {
|
|
let common_prefix: String = match linter.common_prefix() {
|
|
"" => linter
|
|
.upstream_categories()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|c| c.prefix)
|
|
.join("-"),
|
|
prefix => prefix.to_string(),
|
|
};
|
|
let anchor = format!(
|
|
"{}-{}",
|
|
linter.name().to_lowercase(),
|
|
common_prefix.to_lowercase()
|
|
);
|
|
|
|
let _ = write!(
|
|
output,
|
|
"Derived from the **[{}](../rules.md#{})** linter.",
|
|
linter.name(),
|
|
anchor,
|
|
);
|
|
output.push('\n');
|
|
output.push('\n');
|
|
}
|
|
|
|
if rule.is_deprecated() {
|
|
output.push_str(
|
|
r"**Warning: This rule is deprecated and will be removed in a future release.**",
|
|
);
|
|
output.push('\n');
|
|
output.push('\n');
|
|
}
|
|
|
|
if rule.is_removed() {
|
|
output.push_str(
|
|
r"**Warning: This rule has been removed and its documentation is only available for historical reasons.**",
|
|
);
|
|
output.push('\n');
|
|
output.push('\n');
|
|
}
|
|
|
|
let fix_availability = rule.fixable();
|
|
if matches!(
|
|
fix_availability,
|
|
FixAvailability::Always | FixAvailability::Sometimes
|
|
) {
|
|
output.push_str(&fix_availability.to_string());
|
|
output.push('\n');
|
|
output.push('\n');
|
|
}
|
|
|
|
if rule.is_preview() {
|
|
output.push_str(
|
|
r"This rule is unstable and in [preview](../preview.md). The `--preview` flag is required for use.",
|
|
);
|
|
output.push('\n');
|
|
output.push('\n');
|
|
}
|
|
|
|
process_documentation(
|
|
explanation.trim(),
|
|
&mut output,
|
|
&rule.noqa_code().to_string(),
|
|
);
|
|
|
|
let filename = PathBuf::from(ROOT_DIR)
|
|
.join("docs")
|
|
.join("rules")
|
|
.join(&*rule.name())
|
|
.with_extension("md");
|
|
|
|
if args.dry_run {
|
|
println!("{output}");
|
|
} else {
|
|
fs::create_dir_all("docs/rules")?;
|
|
fs::write(filename, output)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn process_documentation(documentation: &str, out: &mut String, rule_name: &str) {
|
|
let mut in_options = false;
|
|
let mut after = String::new();
|
|
let mut referenced_options = HashSet::new();
|
|
|
|
// HACK: This is an ugly regex hack that's necessary because mkdocs uses
|
|
// a non-CommonMark-compliant Markdown parser, which doesn't support code
|
|
// tags in link definitions
|
|
// (see https://github.com/Python-Markdown/markdown/issues/280).
|
|
let documentation = Regex::new(r"\[`([^`]*?)`]($|[^\[(])").unwrap().replace_all(
|
|
documentation,
|
|
|caps: &Captures| {
|
|
format!(
|
|
"[`{option}`][{option}]{sep}",
|
|
option = &caps[1],
|
|
sep = &caps[2]
|
|
)
|
|
},
|
|
);
|
|
|
|
for line in documentation.split_inclusive('\n') {
|
|
if line.starts_with("## ") {
|
|
in_options = line == "## Options\n";
|
|
} else if in_options {
|
|
if let Some(rest) = line.strip_prefix("- `") {
|
|
let option = rest.trim_end().trim_end_matches('`');
|
|
|
|
match Options::metadata().find(option) {
|
|
Some(OptionEntry::Field(field)) => {
|
|
if field.deprecated.is_some() {
|
|
eprintln!("Rule {rule_name} references deprecated option {option}.");
|
|
}
|
|
}
|
|
Some(_) => {}
|
|
None => {
|
|
panic!("Unknown option {option} referenced by rule {rule_name}");
|
|
}
|
|
}
|
|
|
|
let anchor = option.replace('.', "_");
|
|
let _ = writeln!(out, "- [`{option}`][{option}]");
|
|
let _ = writeln!(&mut after, "[{option}]: ../settings.md#{anchor}");
|
|
referenced_options.insert(option);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
out.push_str(line);
|
|
}
|
|
|
|
let re = Regex::new(r"\[`([^`]*?)`]\[(.*?)]").unwrap();
|
|
for (_, [option, _]) in re.captures_iter(&documentation).map(|c| c.extract()) {
|
|
if let Some(OptionEntry::Field(field)) = Options::metadata().find(option) {
|
|
if referenced_options.insert(option) {
|
|
let anchor = option.replace('.', "_");
|
|
let _ = writeln!(&mut after, "[{option}]: ../settings.md#{anchor}");
|
|
}
|
|
if field.deprecated.is_some() {
|
|
eprintln!("Rule {rule_name} references deprecated option {option}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
if !after.is_empty() {
|
|
out.push('\n');
|
|
out.push('\n');
|
|
out.push_str(&after);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::process_documentation;
|
|
|
|
#[test]
|
|
fn test_process_documentation() {
|
|
let mut output = String::new();
|
|
process_documentation(
|
|
"
|
|
See also [`lint.mccabe.max-complexity`] and [`lint.task-tags`].
|
|
Something [`else`][other]. Some [link](https://example.com).
|
|
|
|
## Options
|
|
|
|
- `lint.task-tags`
|
|
- `lint.mccabe.max-complexity`
|
|
|
|
[other]: http://example.com.",
|
|
&mut output,
|
|
"example",
|
|
);
|
|
assert_eq!(
|
|
output,
|
|
"
|
|
See also [`lint.mccabe.max-complexity`][lint.mccabe.max-complexity] and [`lint.task-tags`][lint.task-tags].
|
|
Something [`else`][other]. Some [link](https://example.com).
|
|
|
|
## Options
|
|
|
|
- [`lint.task-tags`][lint.task-tags]
|
|
- [`lint.mccabe.max-complexity`][lint.mccabe.max-complexity]
|
|
|
|
[other]: http://example.com.
|
|
|
|
[lint.task-tags]: ../settings.md#lint_task-tags
|
|
[lint.mccabe.max-complexity]: ../settings.md#lint_mccabe_max-complexity
|
|
"
|
|
);
|
|
}
|
|
}
|