From f5d2691a55a30ebe096e1e6b75eec85c942574a3 Mon Sep 17 00:00:00 2001 From: ashutosh0x Date: Mon, 19 Jan 2026 16:51:32 +0530 Subject: [PATCH 1/5] feat(formatter): support regex in experimentalSortImports.customGroups --- Cargo.lock | 1 + crates/oxc_formatter/Cargo.toml | 1 + .../sort_imports/group_matcher.rs | 21 +++--- .../src/ir_transform/sort_imports/options.rs | 2 + .../sort_imports/custom_groups.rs | 74 +++++++++++++++++++ npm/oxfmt/configuration_schema.json | 6 +- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 202dd73262999..691a28e8f38c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,6 +1922,7 @@ version = "0.25.0" dependencies = [ "cow-utils", "insta", + "lazy-regex", "natord", "nodejs-built-in-modules", "oxc_allocator", diff --git a/crates/oxc_formatter/Cargo.toml b/crates/oxc_formatter/Cargo.toml index 183f45e356e2b..33014bfe33dc0 100644 --- a/crates/oxc_formatter/Cargo.toml +++ b/crates/oxc_formatter/Cargo.toml @@ -34,6 +34,7 @@ nodejs-built-in-modules = { workspace = true } phf = { workspace = true, features = ["macros"] } rustc-hash = { workspace = true } unicode-width = { workspace = true } +lazy-regex = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs index 1670de4bf6204..412f483be6f8c 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs @@ -1,3 +1,4 @@ +use lazy_regex::Regex; use rustc_hash::{FxHashMap, FxHashSet}; use super::group_config::{GroupName, ImportModifier, ImportSelector}; @@ -11,8 +12,8 @@ pub struct ImportMetadata<'a> { } pub struct GroupMatcher { - // Custom groups that are used in `options.groups` - custom_groups: Vec<(CustomGroupDefinition, usize)>, + // Custom groups matching regex patterns + custom_groups: Vec<(Vec, usize)>, // Predefined groups sorted by priority, // so that we don't need to enumerate all possible group names of a given import. @@ -43,11 +44,16 @@ impl GroupMatcher { } } - let mut used_custom_groups: Vec<(CustomGroupDefinition, usize)> = + let mut used_custom_groups: Vec<(Vec, usize)> = Vec::with_capacity(used_custom_group_index_map.len()); for custom_group in custom_groups { if let Some(index) = used_custom_group_index_map.get(&custom_group.group_name) { - used_custom_groups.push((custom_group.clone(), *index)); + let patterns = custom_group + .element_name_pattern + .iter() + .filter_map(|p| Regex::new(p).ok()) + .collect::>(); + used_custom_groups.push((patterns, *index)); } } @@ -61,11 +67,8 @@ impl GroupMatcher { } pub fn compute_group_index(&self, import_metadata: &ImportMetadata) -> usize { - for (custom_group, index) in &self.custom_groups { - let is_match = custom_group - .element_name_pattern - .iter() - .any(|pattern| import_metadata.source.starts_with(pattern)); + for (patterns, index) in &self.custom_groups { + let is_match = patterns.iter().any(|regex| regex.is_match(import_metadata.source)); if is_match { return *index; } diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs index 9febd6973c007..34e0881c8b139 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs @@ -98,6 +98,8 @@ impl fmt::Display for SortOrder { #[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct CustomGroupDefinition { pub group_name: String, + /// Regular expressions to match an element name. + /// The first definition that matches an element will be used. pub element_name_pattern: Vec, } diff --git a/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs b/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs index 5413c67b42fc5..39684bc469dfc 100644 --- a/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs +++ b/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs @@ -286,3 +286,77 @@ import CartComponentB from "./cart/CartComponentB.vue"; "#, ); } + +#[test] +fn should_support_regex_in_custom_groups() { + assert_format( + r#" +import react from "react"; +import { useState } from "react-dom"; +import { loot } from "loot-core"; +import { desktop } from "@desktop-client/main"; +import { other } from "other-package"; +"#, + r#" +{ + "experimentalSortImports": { + "groups": [ + "react", + "loot-core", + "desktop-client", + "external" + ], + "customGroups": [ + { + "groupName": "react", + "elementNamePattern": ["^react(-.*)?$"] + }, + { + "groupName": "loot-core", + "elementNamePattern": ["^loot-core"] + }, + { + "groupName": "desktop-client", + "elementNamePattern": ["^@desktop-client"] + } + ] + } +} +"#, + r#" +import react from "react"; +import { useState } from "react-dom"; + +import { loot } from "loot-core"; + +import { desktop } from "@desktop-client/main"; + +import { other } from "other-package"; +"#, + ); +} + +#[test] +fn should_handle_invalid_regex_gracefully() { + assert_format( + r#" +import { a } from "abc"; +"#, + r#" +{ + "experimentalSortImports": { + "groups": ["custom", "unknown"], + "customGroups": [ + { + "groupName": "custom", + "elementNamePattern": ["[" ] + } + ] + } +} +"#, + r#" +import { a } from "abc"; +"#, + ); +} diff --git a/npm/oxfmt/configuration_schema.json b/npm/oxfmt/configuration_schema.json index 693878cc7ce1c..5ac015d83d38d 100644 --- a/npm/oxfmt/configuration_schema.json +++ b/npm/oxfmt/configuration_schema.json @@ -265,13 +265,13 @@ "type": "object", "properties": { "elementNamePattern": { - "description": "List of import name prefixes to match for this group.", + "description": "List of regular expressions to match for this group.", "default": [], "type": "array", "items": { "type": "string" }, - "markdownDescription": "List of import name prefixes to match for this group." + "markdownDescription": "List of regular expressions to match for this group." }, "groupName": { "description": "Name of the custom group, used in the `groups` option.", @@ -795,4 +795,4 @@ } }, "markdownDescription": "Configuration options for the Oxfmt.\n\nMost options are the same as Prettier's options, but not all of them.\nIn addition, some options are our own extensions." -} +} \ No newline at end of file From bd17bd5263d71d60b1fa2bc316a16c03d51b7270 Mon Sep 17 00:00:00 2001 From: ashutosh0x Date: Mon, 19 Jan 2026 17:21:43 +0530 Subject: [PATCH 2/5] fix(formatter): address PR feedback - Handle invalid regex patterns matching with error logging - Optimize empty groups - Auto-anchor patterns for backward compatibility --- .../sort_imports/group_matcher.rs | 25 ++++++++++++++-- .../sort_imports/custom_groups.rs | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs index 412f483be6f8c..24f6b673f9889 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs @@ -51,9 +51,30 @@ impl GroupMatcher { let patterns = custom_group .element_name_pattern .iter() - .filter_map(|p| Regex::new(p).ok()) + .filter_map(|p| { + let anchored = if p.starts_with('^') { + p.to_owned() + } else { + format!("^{}", p) + }; + match Regex::new(&anchored) { + Ok(regex) => Some(regex), + Err(err) => { + eprintln!( + "oxc_formatter: invalid regex pattern `{}` in custom group `{}`: {}", + p, + custom_group.group_name, + err + ); + None + } + } + }) .collect::>(); - used_custom_groups.push((patterns, *index)); + + if !patterns.is_empty() { + used_custom_groups.push((patterns, *index)); + } } } diff --git a/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs b/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs index 39684bc469dfc..2a3c51368cea4 100644 --- a/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs +++ b/crates/oxc_formatter/tests/ir_transform/sort_imports/custom_groups.rs @@ -360,3 +360,33 @@ import { a } from "abc"; "#, ); } + +#[test] +fn should_maintain_backward_compatibility_for_prefixes() { + assert_format( + r#" +import { a } from "my-react"; // Should NOT match "react" group +import { b } from "react-dom"; // Should match "react" group +import { c } from "react"; // Should match "react" group +"#, + r#" +{ + "experimentalSortImports": { + "groups": ["react", "unknown"], + "customGroups": [ + { + "groupName": "react", + "elementNamePattern": ["react"] + } + ] + } +} +"#, + r#" +import { b } from "react-dom"; // Should match "react" group +import { c } from "react"; // Should match "react" group + +import { a } from "my-react"; // Should NOT match "react" group +"#, + ); +} From 4b4abeffb7fe3db6245c2aaca55488b75dad268d Mon Sep 17 00:00:00 2001 From: ashutosh0x Date: Mon, 19 Jan 2026 17:47:42 +0530 Subject: [PATCH 3/5] feat(linter): implement jsx-a11y/interactive-supports-focus rule --- crates/oxc_linter/src/rules.rs | 2 + .../jsx_a11y/interactive_supports_focus.rs | 172 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 crates/oxc_linter/src/rules/jsx_a11y/interactive_supports_focus.rs diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index e888a64d1b9b5..bd1fd74873612 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -555,6 +555,7 @@ pub(crate) mod jsx_a11y { pub mod html_has_lang; pub mod iframe_has_title; pub mod img_redundant_alt; + pub mod interactive_supports_focus; pub mod label_has_associated_control; pub mod lang; pub mod media_has_caption; @@ -993,6 +994,7 @@ oxc_macros::declare_all_lint_rules! { jsx_a11y::html_has_lang, jsx_a11y::iframe_has_title, jsx_a11y::img_redundant_alt, + jsx_a11y::interactive_supports_focus, jsx_a11y::label_has_associated_control, jsx_a11y::lang, jsx_a11y::media_has_caption, diff --git a/crates/oxc_linter/src/rules/jsx_a11y/interactive_supports_focus.rs b/crates/oxc_linter/src/rules/jsx_a11y/interactive_supports_focus.rs new file mode 100644 index 0000000000000..6f58be8e87cea --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/interactive_supports_focus.rs @@ -0,0 +1,172 @@ +use oxc_ast::{AstKind, ast::JSXAttributeItem}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + AstNode, + context::LintContext, + globals::HTML_TAG, + rule::Rule, + utils::{ + get_element_type, has_jsx_prop, is_hidden_from_screen_reader, is_interactive_element, + is_presentation_role, + }, +}; + +fn interactive_supports_focus_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Elements with interactive roles must be focusable.") + .with_help("Interactive elements must be able to receive focus. Add a valid `tabIndex` attribute.") + .with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct InteractiveSupportsFocus; + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforces that elements with interactive roles are focusable. + /// + /// ### Why is this bad? + /// + /// Interactive elements that are not focusable cannot be accessed by keyboard users, + /// making them inaccessible to users with disabilities who rely on keyboard navigation. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```jsx + ///
{}} /> + /// {}} /> + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```jsx + ///
{}} tabIndex="0" /> + ///