diff --git a/crates/ruff_dev/src/generate_rules_table.rs b/crates/ruff_dev/src/generate_rules_table.rs index 96b4b1f298..713488a902 100644 --- a/crates/ruff_dev/src/generate_rules_table.rs +++ b/crates/ruff_dev/src/generate_rules_table.rs @@ -2,19 +2,17 @@ //! //! Used for . -use itertools::Itertools; -use ruff_linter::codes::RuleStatus; -use std::borrow::Cow; +use std::collections::{BTreeMap, HashSet}; use std::fmt::Write; + +use itertools::Itertools; use strum::IntoEnumIterator; use ruff_linter::FixAvailability; +use ruff_linter::codes::{Category, RuleStatus}; use ruff_linter::registry::{Linter, Rule, RuleNamespace}; use ruff_linter::settings::LinterSettings; use ruff_linter::settings::rule_table::RuleTable; -use ruff_linter::upstream_categories::UpstreamCategoryAndPrefix; -use ruff_options_metadata::OptionsMetadata; -use ruff_workspace::options::Options; const DEFAULT_SYMBOL: &str = "✅"; const FIX_SYMBOL: &str = "🛠️"; @@ -33,26 +31,26 @@ const SYMBOLS_CONTAINER: &str = fn generate_table( table_out: &mut String, rules: impl IntoIterator, - linter: Option<&Linter>, default_rules: &RuleTable, ) { - if linter.is_some() { - table_out.push_str("| Code { scope='col' } "); - } - table_out.push_str( - "| Name { scope='col' } | Message { scope='col' } | \ - \ - Status/Fix/Default\ - { scope='col' } |", + let table_start = table_out.len(); + table_out.push('\n'); + let default_categories = Category::default_categories().iter().join(" "); + let _ = writeln!( + table_out, + "| Code {{ scope='col' .rule-code }} \ + | Rule {{ scope='col' .rule-identity }} \ + | Category {{ scope='col' .rule-category data-default-categories='{default_categories}' }} \ + | Linter {{ scope='col' .rule-linter }} \ + | Status {{ scope='col' .rule-status aria-label='Status, fix availability, and default selection' }} |" ); + table_out.push_str("| ---- | ---- | -------- | ------ | -: |"); table_out.push('\n'); - if linter.is_some() { - table_out.push_str("| ---- "); - } - table_out.push_str("| ---- | ------- | -: |"); - table_out.push('\n'); + let mut seen_anchors = HashSet::new(); + let mut linters = BTreeMap::new(); for rule in rules { - let status_token = match rule.status() { + let status = rule.status(); + let status_token = match status { RuleStatus::Removed { since } => { format!( "Rule was removed in {since}" @@ -75,13 +73,15 @@ fn generate_table( } }; - let fix_token = match rule.fixable() { - FixAvailability::Always | FixAvailability::Sometimes => { - format!( - "Automatic fix available" - ) - } - FixAvailability::None => format!(""), + let fix_token = if matches!( + rule.fixable(), + FixAvailability::Always | FixAvailability::Sometimes + ) { + format!( + "Automatic fix available" + ) + } else { + format!("") }; let default_token = if default_rules.enabled(rule) { @@ -94,15 +94,7 @@ fn generate_table( let rule_name = rule.name(); - // If the message ends in a bracketed expression (like: "Use {replacement}"), escape the - // brackets. Otherwise, it'll be interpreted as an HTML attribute via the `attr_list` - // plugin. (Above, we'd convert to "Use {replacement\}".) let message = rule.message_formats()[0]; - let message = if let Some(prefix) = message.strip_suffix('}') { - Cow::Owned(format!("{prefix}\\}}")) - } else { - Cow::Borrowed(message) - }; // Start and end of style spans let mut ss = ""; @@ -115,35 +107,114 @@ fn generate_table( se = ""; } - if let Some(linter) = linter { - let _ = write!( - table_out, - "| {ss}{prefix}{code}{se} {{ #{prefix}{code} }} ", - prefix = linter.common_prefix(), - code = linter.code_for_rule(rule).unwrap(), + let code = rule.noqa_code().map(|code| code.to_string()); + let linter = code + .as_deref() + .and_then(|code| Linter::parse_code(code).map(|(linter, _)| linter)); + let (linter_name, linter_slug, linter_label) = if let Some(linter) = &linter { + let prefixes = match linter.common_prefix() { + "" => linter + .upstream_categories() + .into_iter() + .flatten() + .map(|category| category.prefix) + .join(", "), + prefix => prefix.to_string(), + }; + // The linter names for Ruff and NumPy are suffixed with "-specific rules." + let name = linter.name().trim_end_matches("-specific rules"); + let slug = format!( + "{}-{}", + linter.name().to_lowercase().replace(' ', "-"), + prefixes.to_lowercase().replace(", ", "-"), ); + (name, slug, format!("{name} ({prefixes})")) + } else { + ( + "—", + "rules-without-codes".to_string(), + "No originating linter".to_string(), + ) + }; + // Preserve links to the old linter headings on their first table row. + let linter_anchor = if seen_anchors.insert(linter_slug.clone()) { + linters.insert( + linter_label.to_lowercase(), + (linter_slug.clone(), linter_label.clone()), + ); + format!("#{linter_slug}") + } else { + String::new() + }; + + if let Some(code) = code { + table_out.push_str("| "); + // The former subgroups have separate anchors in addition to each rule's code. + if let Some(linter) = linter + && let Some(category) = rule.upstream_category(&linter) + { + let slug = format!( + "{}-{}{}", + category.category.to_lowercase().replace(' ', "-"), + linter.common_prefix().to_lowercase(), + category.prefix.to_lowercase(), + ); + if seen_anchors.insert(slug.clone()) { + let _ = write!(table_out, ""); + } + } + let _ = write!(table_out, "{ss}`{code}`{se} {{ #{code} .rule-code }} "); + } else { + let _ = write!(table_out, "| {ss}—{se} {{ .rule-code }} "); } + // Message placeholders can be mistaken for an attribute list on the table cell. + // Keep the identity class on an HTML wrapper so messages retain their Markdown. #[expect(clippy::or_fun_call)] let _ = write!( table_out, - "| {ss}{explanation}{se} | {ss}{message}{se} |
{status_token}{fix_token}{default_token}
|", + "| {ss}{explanation}
{message}{se}
\ + | {ss}{category}{se} {{ .rule-category }} \ + | {ss}{linter_name}{se} {{ {linter_anchor} .rule-linter data-linter='{linter_slug}' }} \ + |
{status_token}{fix_token}{default_token}
{{ .rule-status data-status='{status}' }} |", + category = rule.category(), explanation = rule .explanation() .is_some() - .then_some(format_args!("[{rule_name}](rules/{rule_name}.md)")) - .unwrap_or(format_args!("{rule_name}")), + .then_some(format_args!("[`{rule_name}`](rules/{rule_name}.md)")) + .unwrap_or(format_args!("`{rule_name}`")), ); table_out.push('\n'); } table_out.push('\n'); + + // Reuse the table's linter labels and slugs for the filter options. + let category_options = Category::iter() + .map(|category| { + format!("") + }) + .join("\n"); + let linter_options = linters + .values() + .map(|(value, label)| { + format!("") + }) + .join("\n"); + table_out.insert_str( + table_start, + &format!( + include_str!("../../../docs/.overrides/partials/rule-filters.html"), + category_options = category_options, + linter_options = linter_options, + ), + ); } pub(crate) fn generate() -> String { // Generate the table string. let mut table_out = String::new(); - table_out.push_str("### Legend"); + table_out.push_str("
\nRule legend\n\n### Legend"); table_out.push('\n'); let _ = write!( @@ -176,114 +247,15 @@ pub(crate) fn generate() -> String { ); table_out.push_str("\n\n"); table_out.push_str("All rules not marked as preview, deprecated or removed are stable."); - table_out.push('\n'); + table_out.push_str("\n\n
\n\n"); - let default_rules = LinterSettings::default().rules; - for linter in Linter::iter() { - let codes_csv: String = match linter.common_prefix() { - "" => linter - .upstream_categories() - .unwrap() - .iter() - .map(|c| c.prefix) - .join(", "), - prefix => prefix.to_string(), - }; - let _ = write!(&mut table_out, "### {} ({codes_csv})", linter.name()); - table_out.push('\n'); - table_out.push('\n'); - - if let Some(url) = linter.url() { - let host = url - .trim_start_matches("https://") - .split('/') - .next() - .unwrap(); - let _ = write!( - table_out, - "For more, see [{}]({}) on {}.", - linter.name(), - url, - match host { - "pypi.org" => "PyPI", - "github.com" => "GitHub", - host => panic!( - "unexpected host in URL of {}, expected pypi.org or github.com but found \ - {host}", - linter.name() - ), - } - ); - table_out.push('\n'); - table_out.push('\n'); - } - - // The linter names for Ruff and NumPy are suffixed with "-specific rules." - let linter_name = linter.name().trim_end_matches("-specific rules"); - // Several linter names are capitalized, but their settings are not. - let linter_name_lower = linter_name.to_lowercase(); - if Options::metadata().has(&format!("lint.{linter_name_lower}")) { - let _ = write!( - table_out, - "For related settings, see [{linter_name}](settings.md#lint{linter_name_lower}).", - ); - table_out.push('\n'); - table_out.push('\n'); - } - - let rules_by_upstream_category = linter - .all_rules() - .map(|rule| (rule.upstream_category(&linter), rule)) - .into_group_map(); - - let mut rules_by_upstream_category: Vec<_> = rules_by_upstream_category.iter().collect(); - - // Sort the upstream categories alphabetically by prefix. - rules_by_upstream_category.sort_by(|(a, _), (b, _)| { - a.as_ref() - .map(|category| category.prefix) - .unwrap_or_default() - .cmp( - b.as_ref() - .map(|category| category.prefix) - .unwrap_or_default(), - ) - }); - - if rules_by_upstream_category.len() > 1 { - for (opt, rules) in rules_by_upstream_category { - if opt.is_some() { - let UpstreamCategoryAndPrefix { category, prefix } = opt.unwrap(); - match codes_csv.as_str() { - "PL" => { - let _ = write!(table_out, "#### {category} ({codes_csv}{prefix})"); - } - _ => { - let _ = write!(table_out, "#### {category} ({prefix})"); - } - } - } - table_out.push('\n'); - table_out.push('\n'); - generate_table(&mut table_out, rules.clone(), Some(&linter), &default_rules); - } - } else { - generate_table( - &mut table_out, - linter.all_rules(), - Some(&linter), - &default_rules, - ); - } - } - - let mut codeless_rules = Rule::iter() - .filter(|rule| rule.noqa_code().is_none()) - .peekable(); - if codeless_rules.peek().is_some() { - table_out.push_str("### Rules without codes\n\n"); - generate_table(&mut table_out, codeless_rules, None, &default_rules); - } + generate_table( + &mut table_out, + Linter::iter() + .flat_map(|linter| linter.all_rules()) + .chain(Rule::iter().filter(|rule| rule.noqa_code().is_none())), + &LinterSettings::default().rules, + ); table_out } diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 18fcdea9b4..fa9a3461a3 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -109,9 +109,9 @@ impl serde::Serialize for NoqaCode { /// ALL < category < linter group < linter prefix < rule /// ``` /// -/// The ordering of variants isn't currently used anywhere, but they should be kept in descending -/// order of severity, with error categories first, followed by warning, and then by off-by-default -/// categories. +/// Variants are ordered by descending severity, with error categories first, followed by warning, +/// and then by off-by-default categories. The rule documentation lists category filter options +/// in this order. /// /// See our [rule categorization guidelines] for more information on assigning categories. /// @@ -223,6 +223,17 @@ pub enum RuleStatus { Removed { since: &'static str }, } +impl std::fmt::Display for RuleStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + RuleStatus::Stable { .. } => "stable", + RuleStatus::Preview { .. } => "preview", + RuleStatus::Deprecated { .. } => "deprecated", + RuleStatus::Removed { .. } => "removed", + }) + } +} + #[ruff_macros::map_codes] pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleStatus, Rule)> { #[expect(clippy::enum_glob_use)] diff --git a/docs/.overrides/partials/rule-filters.html b/docs/.overrides/partials/rule-filters.html new file mode 100644 index 0000000000..deb35afb70 --- /dev/null +++ b/docs/.overrides/partials/rule-filters.html @@ -0,0 +1,59 @@ + diff --git a/docs/js/rule-filters.js b/docs/js/rule-filters.js new file mode 100644 index 0000000000..e245293f87 --- /dev/null +++ b/docs/js/rule-filters.js @@ -0,0 +1,299 @@ +(() => { + let restoreFilters = () => {}; + + document$.subscribe(() => { + restoreFilters = () => {}; + + const form = document.getElementById("rule-filters"); + if (!form || !("popover" in HTMLElement.prototype)) { + return; + } + + const article = form.closest("article"); + const table = article.querySelector(".rule-category")?.closest("table"); + if (!table) { + return; + } + + table.id = "rule-catalog"; + const categoryMetadata = + table.tHead.querySelector(".rule-category").dataset; + const body = table.tBodies[0]; + + const rows = Array.from(body.rows, (element) => { + const [code, identity, category, linter, status] = element.cells; + return { + element, + text: `${code.textContent} ${identity.querySelector("code").textContent}`.toLowerCase(), + category: category.textContent.trim(), + linter: linter.dataset.linter, + status: status.dataset.status, + }; + }); + + const search = form.elements.namedItem("rule-search"); + const count = form.querySelector(".rule-filters__count"); + const empty = form.querySelector(".rule-filters__empty"); + + const filters = [...form.querySelectorAll("[data-filter]")].map((group) => { + const name = group.dataset.filter; + const trigger = group.querySelector("[popovertarget]"); + const panel = group.querySelector("[popover]"); + const selection = trigger.firstElementChild; + const options = [...panel.querySelectorAll("input")]; + const allValues = options.map((option) => option.value); + + const isAll = () => options.every((option) => option.checked); + + const updateSummary = () => { + const labels = options + .filter((option) => option.checked) + .map((option) => option.parentElement.textContent.trim()); + + selection.textContent = + labels.length === options.length + ? "All" + : labels.length === 0 + ? "None" + : labels.length === 1 + ? labels[0] + : `${labels.length} selected`; + trigger.title = + isAll() || !labels.length ? selection.textContent : labels.join(", "); + trigger.setAttribute( + "aria-label", + `${group.closest("fieldset").querySelector("legend").textContent}: ${selection.textContent}`, + ); + }; + + const setValues = (selected = allValues) => { + for (const option of options) { + option.checked = selected.includes(option.value); + } + }; + + const presets = { + all: allValues, + default: categoryMetadata.defaultCategories.split(" "), + none: [], + }; + + for (const button of panel.querySelectorAll("[data-preset]")) { + button.addEventListener("click", () => { + setValues(presets[button.dataset.preset]); + applyFilters(); + }); + } + + const controls = [...panel.querySelectorAll("button, input")]; + group.addEventListener("keydown", (event) => { + if ( + !panel.matches(":popover-open") || + (event.key !== "ArrowDown" && event.key !== "ArrowUp") || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } + + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + const index = controls.indexOf(event.target); + const nextIndex = + index === -1 + ? direction === 1 + ? 0 + : controls.length - 1 + : Math.max(0, Math.min(controls.length - 1, index + direction)); + controls[nextIndex]?.focus(); + }); + + group.addEventListener("focusout", (event) => { + if ( + panel.matches(":popover-open") && + event.relatedTarget && + !group.contains(event.relatedTarget) + ) { + panel.hidePopover(); + } + }); + + return { + name, + isAll, + setValues, + updateSummary, + }; + }); + + function clearFilters() { + search.value = ""; + for (const filter of filters) { + filter.setValues(); + } + } + + const container = table.closest(".md-typeset__scrollwrap") || table; + container.classList.add("rule-catalog-scroll"); + container.setAttribute("role", "region"); + container.setAttribute("aria-label", "Rules"); + container.tabIndex = 0; + form.hidden = false; + + for (const toc of document.querySelectorAll(".md-nav--secondary")) { + toc.hidden = true; + toc.parentElement.classList.add("rule-index-nav"); + } + + function filter() { + for (const filter of filters) { + filter.updateSummary(); + } + + const formData = new FormData(form); + const categories = formData.getAll("category"); + const selectedLinters = formData.getAll("linter"); + const statuses = formData.getAll("status"); + const terms = search.value + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + + let visible = 0; + for (const row of rows) { + const matches = + categories.includes(row.category) && + selectedLinters.includes(row.linter) && + statuses.includes(row.status) && + terms.every((term) => row.text.includes(term)); + + row.element.hidden = !matches; + if (matches) { + visible++; + } + } + + count.textContent = + visible === rows.length + ? `${visible} rules` + : `${visible} of ${rows.length} rules`; + empty.hidden = visible !== 0; + container.hidden = visible === 0; + } + + const fragmentLinks = [...document.querySelectorAll("a[href]")].filter( + (link) => + link.hash && + link.origin === location.origin && + link.pathname === location.pathname, + ); + + function updateFragmentLinks() { + // Material resolves fragment links before filters can change the query. + for (const link of fragmentLinks) { + link.search = location.search; + } + } + + function updateURL(clearFragment = false) { + const formData = new FormData(form); + const url = new URL(location.href); + if (clearFragment) { + url.hash = ""; + } + + if (search.value) { + url.searchParams.set(search.name, search.value); + } else { + url.searchParams.delete(search.name); + } + + for (const filter of filters) { + url.searchParams.delete(filter.name); + if (filter.isAll()) { + continue; + } + + const selected = formData.getAll(filter.name); + // An omitted parameter means All; preserve an explicit empty selection. + if (!selected.length) { + url.searchParams.set(filter.name, "none"); + } + + for (const value of selected) { + url.searchParams.append(filter.name, value); + } + } + + history.replaceState(history.state, "", url); + updateFragmentLinks(); + } + + restoreFilters = () => { + if (!form.isConnected) { + return; + } + + const url = new URL(location.href); + search.value = url.searchParams.get(search.name) || ""; + for (const filter of filters) { + filter.setValues( + url.searchParams.has(filter.name) + ? url.searchParams.getAll(filter.name) + : undefined, + ); + } + + let fragment; + try { + fragment = decodeURIComponent(url.hash.slice(1)); + } catch { + fragment = ""; + } + + filter(); + updateFragmentLinks(); + + const target = fragment && document.getElementById(fragment); + if (!target || !article.contains(target)) { + return; + } + + const details = target.closest("details"); + if (details) { + details.open = true; + } + + if (target.closest("[hidden]")) { + clearFilters(); + filter(); + updateURL(); + target.scrollIntoView(); + } + }; + + function applyFilters() { + filter(); + updateURL(true); + } + + form.addEventListener("input", applyFilters); + + form.addEventListener("submit", (event) => { + event.preventDefault(); + applyFilters(); + }); + + form.addEventListener("reset", (event) => { + event.preventDefault(); + clearFilters(); + applyFilters(); + }); + + restoreFilters(); + }); + + location$.subscribe(() => restoreFilters()); +})(); diff --git a/docs/stylesheets/rule-filters.css b/docs/stylesheets/rule-filters.css new file mode 100644 index 0000000000..ce2198fe47 --- /dev/null +++ b/docs/stylesheets/rule-filters.css @@ -0,0 +1,194 @@ +.md-typeset .rule-filters { + margin: 1em 0; + padding: 0.75rem 0; + border-bottom: 1px solid var(--md-default-fg-color--lightest); +} + +.rule-filters__controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem; +} + +.rule-filters__search, +.rule-filters fieldset { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0.3rem; + font-size: 0.7rem; + font-weight: 600; +} + +.rule-filters fieldset { + margin: 0; + padding: 0; + border: 0; +} + +.rule-filters legend { + margin-bottom: 0.3rem; +} + +.rule-filters [popovertarget] { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.4rem; + width: 100%; + anchor-name: var(--rule-filter-anchor); +} + +.rule-filters [popovertarget] span:first-child { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.rule-filters__options { + width: 14rem; + max-width: calc(100vw - 2rem); + max-height: min(16rem, calc(50dvh - 2rem)); + padding: 0.3rem; + overflow-y: auto; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.2rem; + background: var(--md-default-bg-color); + color: var(--md-default-fg-color); + box-shadow: 0 0.2rem 0.5rem #0002; + position-anchor: var(--rule-filter-anchor); +} + +/* Browsers without anchor positioning keep the popover centered. */ +@supports (top: anchor(bottom)) { + .rule-filters__options { + inset: auto; + top: anchor(bottom); + left: anchor(left); + width: anchor-size(width); + margin: 0.25rem 0 0; + position-try-fallbacks: flip-block; + } +} + +.rule-filters__options label { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem; + font-weight: 400; + overflow-wrap: anywhere; + cursor: pointer; +} + +.rule-filters__options label:hover, +.rule-filters__actions button:hover { + background: var(--md-code-bg-color); +} + +.rule-filters__options input { + flex: none; + width: 0.8rem; + height: 0.8rem; + accent-color: var(--md-typeset-a-color); +} + +.rule-filters__actions { + display: flex; + flex-direction: column; + margin-bottom: 0.3rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--md-default-fg-color--lightest); +} + +.rule-filters .rule-filters__actions button { + width: 100%; + border: 0; + border-radius: 0; + text-align: center; +} + +.rule-filters__search { + flex: 2 1 12rem; +} + +.rule-filters__category { + --rule-filter-anchor: --rule-category; + flex: 1 1 8rem; +} + +.rule-filters__linter { + --rule-filter-anchor: --rule-linter; + flex: 1 1 10rem; +} + +.rule-filters__status { + --rule-filter-anchor: --rule-status; + flex: 1 1 7rem; +} + +.rule-filters input[type="search"], +.rule-filters button { + min-width: 0; + min-height: 2.1rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.2rem; + background: var(--md-default-bg-color); + color: var(--md-default-fg-color); + font: inherit; + font-weight: 400; +} + +.rule-filters input::placeholder { + color: var(--md-default-fg-color--light); +} + +.rule-filters button { + font-size: 0.7rem; + cursor: pointer; +} + +.rule-filters button:hover { + border-color: var(--md-typeset-a-color); + color: var(--md-typeset-a-color); +} + +.rule-filters :is(input, button):focus-visible, +.rule-catalog-scroll:focus-visible { + outline: 2px solid var(--md-typeset-a-color); + outline-offset: 2px; +} + +.rule-filters__actions button:focus-visible { + outline-offset: -2px; +} + +.md-typeset .rule-filters__count { + margin: 0.5rem 0 0; + color: var(--md-default-fg-color--light); + font-size: 0.65rem; +} + +#rule-catalog [hidden], +.rule-catalog-scroll[hidden], +.rule-filters [hidden], +.rule-index-nav > label[for="__toc"], +.md-nav--secondary[hidden] { + display: none !important; +} + +.rule-index-nav > a.md-nav__link { + display: flex !important; +} + +@media (max-width: 59.999em) { + .rule-filters__search { + flex-basis: 100%; + } + + .rule-filters__linter { + flex-basis: 8rem; + } +} diff --git a/mkdocs.template.yml b/mkdocs.template.yml index c60cf74a52..5b78cfbeac 100644 --- a/mkdocs.template.yml +++ b/mkdocs.template.yml @@ -104,8 +104,10 @@ plugins: - faq.md extra_css: - stylesheets/extra.css + - stylesheets/rule-filters.css extra_javascript: - js/extra.js + - js/rule-filters.js not_in_nav: | /rules/* /formatter/*