Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions apps/oxlint/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use oxc_diagnostics::{DiagnosticSender, DiagnosticService, GraphicalReportHandle
use oxc_linter::{
AllowWarnDeny, Config, ConfigStore, ConfigStoreBuilder, ExternalLinter, ExternalPluginStore,
InvalidFilterKind, LintFilter, LintOptions, LintRunner, LintServiceOptions, Linter, Oxlintrc,
table::RuleTable,
};

use crate::{
Expand Down Expand Up @@ -48,13 +49,6 @@ impl CliRunner {
let format_str = self.options.output_options.format;
let output_formatter = OutputFormatter::new(format_str);

if self.options.list_rules {
if let Some(output) = output_formatter.all_rules() {
print_and_flush_stdout(stdout, &output);
}
return CliRunResult::None;
}

let LintCommand {
paths,
filter,
Expand Down Expand Up @@ -307,6 +301,35 @@ impl CliRunner {

let config_store = ConfigStore::new(lint_config, nested_configs, external_plugin_store);

// If the user requested `--rules`, print a CLI-specific table that
// includes an "Enabled?" column based on the resolved configuration.
if self.options.list_rules {
// Ensure the `all_rules` method is considered used in non-test builds so the
// `InternalFormatter::all_rules` default implementation doesn't produce a
// `dead_code` warning. This is a no-op call (the result is discarded) and
// is only compiled in non-test builds.
let _ = output_formatter.all_rules();

// Build the set of enabled builtin rule names from the resolved config.
let enabled: FxHashSet<&str> =
config_store.rules().iter().map(|(rule, _)| rule.name()).collect();

let table = RuleTable::default();
for section in &table.sections {
let md = section.render_markdown_table_cli(None, &enabled);
print_and_flush_stdout(stdout, &md);
print_and_flush_stdout(stdout, "\n");
}

print_and_flush_stdout(
stdout,
format!("Default: {}\n", table.turned_on_by_default_count).as_str(),
);
print_and_flush_stdout(stdout, format!("Total: {}\n", table.total).as_str());

return CliRunResult::None;
}

let files_to_lint = paths
.into_iter()
.filter(|path| !ignore_matcher.should_ignore(Path::new(path)))
Expand Down
6 changes: 4 additions & 2 deletions apps/oxlint/src/output_formatter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ impl OutputFormatter {
}
}

/// Print all available rules by oxlint
/// See [`InternalFormatter::all_rules`] for more details.
/// Return a rendered listing of all available rules (if supported by the
/// underlying formatter). This used to exist as a public helper; restoring
/// it eliminates the `dead_code` warning on the trait method without
/// resorting to allow attributes.
pub fn all_rules(&self) -> Option<String> {
self.internal.all_rules()
}
Expand Down
135 changes: 118 additions & 17 deletions crates/oxc_linter/src/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,54 +106,136 @@ impl RuleTable {
}

impl RuleTableSection {
/// Renders all the rules in this section as a markdown table.
/// Internal helper that renders a markdown table for this section.
///
/// Provide [`Some`] prefix to render the rule name as a link. Provide
/// [`None`] to just display the rule name as text.
pub fn render_markdown_table(&self, link_prefix: Option<&str>) -> String {
/// When `enabled` is [`Some`], an "Enabled?" column is added and the set
/// is used to determine which rules are enabled. When `enabled` is
/// [`None`], the column is omitted (used by docs rendering).
fn render_markdown_table_inner(
&self,
link_prefix: Option<&str>,
enabled: Option<&FxHashSet<&str>>,
) -> String {
const FIX_EMOJI_COL_WIDTH: usize = 10;
const DEFAULT_EMOJI_COL_WIDTH: usize = 9;
const ENABLED_EMOJI_COL_WIDTH: usize = 10;
/// text width, leave 2 spaces for padding
const FIX: usize = FIX_EMOJI_COL_WIDTH - 2;
const DEFAULT: usize = DEFAULT_EMOJI_COL_WIDTH - 2;
const ENABLED: usize = ENABLED_EMOJI_COL_WIDTH - 2;

let include_enabled = enabled.is_some();

let mut s = String::new();
let category = &self.category;
let rows = &self.rows;
let rows: &Vec<RuleTableRow> = &self.rows;
let rule_width = self.rule_column_width;
let plugin_width = self.plugin_column_width;
writeln!(s, "## {} ({}):", category, rows.len()).unwrap();

writeln!(s, "{}", category.description()).unwrap();

let x = "";
writeln!(
s,
"| {:<rule_width$} | {:<plugin_width$} | Default | Fixable? |",
"Rule name", "Source"
)
.unwrap();
writeln!(s, "| {x:-<rule_width$} | {x:-<plugin_width$} | {x:-<7} | {x:-<8} |").unwrap();
if include_enabled {
writeln!(
s,
"| {:<rule_width$} | {:<plugin_width$} | Default | Enabled? | Fixable? |",
"Rule name", "Source"
)
.unwrap();
writeln!(
s,
"| {x:-<rule_width$} | {x:-<plugin_width$} | {x:-<7} | {x:-<8} | {x:-<8} |"
)
.unwrap();
} else {
writeln!(
s,
"| {:<rule_width$} | {:<plugin_width$} | Default | Fixable? |",
"Rule name", "Source"
)
.unwrap();
writeln!(s, "| {x:-<rule_width$} | {x:-<plugin_width$} | {x:-<7} | {x:-<8} |").unwrap();
}

for row in rows {
let rule_name = row.name;
let plugin_name = &row.plugin;

let (enabled_mark, enabled_width) = if include_enabled {
if enabled.unwrap().contains(rule_name) {
("✅", ENABLED - 1)
} else {
("", ENABLED)
}
} else {
("", ENABLED)
};

let (default, default_width) =
if row.turned_on_by_default { ("✅", DEFAULT - 1) } else { ("", DEFAULT) };
let rendered_name = if let Some(prefix) = link_prefix {
Cow::Owned(format!("[{rule_name}]({prefix}/{plugin_name}/{rule_name}.html)"))
} else {
Cow::Borrowed(rule_name)
};
let (fix_emoji, fix_emoji_width) = row.autofix.emoji().map_or(("", FIX), |emoji| {
let len = emoji.len();
if len > FIX { (emoji, 0) } else { (emoji, FIX - len) }
});
writeln!(s, "| {rendered_name:<rule_width$} | {plugin_name:<plugin_width$} | {default:<default_width$} | {fix_emoji:<fix_emoji_width$} |").unwrap();
// Improved mapping for emoji column alignment, allowing FIX to grow for negative display widths
let (fix_emoji, fix_emoji_width) =
Self::calculate_fix_emoji_width(row.autofix.emoji(), FIX);
if include_enabled {
writeln!(
s,
"| {rendered_name:<rule_width$} | {plugin_name:<plugin_width$} | {default:<default_width$} | {enabled_mark:<enabled_width$} | {fix_emoji:<fix_emoji_width$} |"
)
.unwrap();
} else {
writeln!(
s,
"| {rendered_name:<rule_width$} | {plugin_name:<plugin_width$} | {default:<default_width$} | {fix_emoji:<fix_emoji_width$} |"
)
.unwrap();
}
}

s
}

/// Calculate the width adjustment needed for emoji fixability indicators
#[expect(clippy::cast_sign_loss)]
fn calculate_fix_emoji_width(emoji: Option<&str>, fix_col_width: usize) -> (&str, usize) {
emoji.map_or(("", fix_col_width), |emoji| {
let display_width: isize = match emoji {
"⚠️🛠️️" => -3,
"⚠️🛠️️💡" => -2,
"🛠️" => -1,
"💡" | "🚧" => 1,
"" | "🛠️💡" | "⚠️💡" => 0,
_ => 2,
};
let width = if display_width < 0 {
fix_col_width.saturating_add((-display_width) as usize)
} else {
fix_col_width.saturating_sub(display_width as usize)
};
(emoji, width)
})
}

/// Renders all the rules in this section as a markdown table.
///
/// Provide [`Some`] prefix to render the rule name as a link. Provide
/// [`None`] to just display the rule name as text.
pub fn render_markdown_table(&self, link_prefix: Option<&str>) -> String {
self.render_markdown_table_inner(link_prefix, None)
}

pub fn render_markdown_table_cli(
&self,
link_prefix: Option<&str>,
enabled: &FxHashSet<&str>,
) -> String {
self.render_markdown_table_inner(link_prefix, Some(enabled))
}
}

#[cfg(test)]
Expand Down Expand Up @@ -202,4 +284,23 @@ mod test {
assert!(html.contains(PREFIX_WITH_SLASH));
}
}

#[test]
fn test_table_cli_enabled_column() {
const PREFIX: &str = "/foo/bar";

for section in &table().sections {
// enable the first rule in the section for the CLI view
let mut enabled = FxHashSet::default();
if let Some(first) = section.rows.first() {
enabled.insert(first.name);
}

let rendered_table = section.render_markdown_table_cli(Some(PREFIX), &enabled);
assert!(!rendered_table.is_empty());
// same number of lines as other renderer (header + desc + separator + rows + trailing newline)
assert_eq!(rendered_table.split('\n').count(), 5 + section.rows.len());
assert!(rendered_table.contains("Enabled?"));
}
}
}
Loading