diff --git a/Cargo.lock b/Cargo.lock index 9cd4378c1f27..05b99955c80f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,6 +750,7 @@ dependencies = [ "biome_unicode_table", "bitvec", "enumflags2", + "globset", "insta", "natord", "regex", diff --git a/Cargo.toml b/Cargo.toml index 80400dac1628..5e6a026c6cf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,6 +178,7 @@ crossbeam = "0.8.4" dashmap = "6.1.0" enumflags2 = "0.7.10" getrandom = "0.2.15" +globset = "0.4.15" ignore = "0.4.23" indexmap = { version = "2.6.0", features = ["serde"] } insta = "1.40.0" diff --git a/crates/biome_js_analyze/Cargo.toml b/crates/biome_js_analyze/Cargo.toml index 064caedee302..455caf01b4ea 100644 --- a/crates/biome_js_analyze/Cargo.toml +++ b/crates/biome_js_analyze/Cargo.toml @@ -28,6 +28,7 @@ biome_suppression = { workspace = true } biome_unicode_table = { workspace = true } bitvec = "1.0.1" enumflags2 = { workspace = true } +globset = { workspace = true } natord = { workspace = true } regex = { workspace = true } roaring = "0.10.6" diff --git a/crates/biome_js_analyze/src/assists/source/organize_imports.rs b/crates/biome_js_analyze/src/assists/source/organize_imports.rs index ab009d98aa41..705b7bf883ab 100644 --- a/crates/biome_js_analyze/src/assists/source/organize_imports.rs +++ b/crates/biome_js_analyze/src/assists/source/organize_imports.rs @@ -2,10 +2,12 @@ use biome_analyze::{ context::RuleContext, declare_source_rule, ActionCategory, Ast, FixKind, Rule, SourceActionKind, }; use biome_console::markup; +use biome_deserialize::Deserializable; +use biome_deserialize_macros::Deserializable; use biome_js_syntax::JsModule; use biome_rowan::BatchMutationExt; -use crate::JsRuleAction; +use crate::{utils::restricted_glob::RestrictedGlob, JsRuleAction}; pub mod util; pub mod legacy; @@ -47,7 +49,7 @@ impl Rule for OrganizeImports { type Query = Ast; type State = State; type Signals = Option; - type Options = (); + type Options = Options; fn run(ctx: &RuleContext) -> Option { let root = ctx.query(); @@ -76,3 +78,47 @@ pub enum State { Legacy(legacy::ImportGroups), Modern, } + +#[derive(Clone, Debug, Default, serde::Deserialize, Deserializable, serde::Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +pub struct Options { + legacy: bool, + import_groups: Box<[ImportGroup]>, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum ImportGroup { + Predefined(PredefinedImportGroup), + Custom(RestrictedGlob), +} +impl Deserializable for ImportGroup { + fn deserialize( + value: &impl biome_deserialize::DeserializableValue, + name: &str, + diagnostics: &mut Vec, + ) -> Option { + Some( + if let Some(predefined) = Deserializable::deserialize(value, name, diagnostics) { + ImportGroup::Predefined(predefined) + } else { + ImportGroup::Custom(Deserializable::deserialize(value, name, diagnostics)?) + }, + ) + } +} + +#[derive(Clone, Debug, serde::Deserialize, Deserializable, Eq, PartialEq, serde::Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub enum PredefinedImportGroup { + #[serde(rename = ":blank-line:")] + BlankLine, + #[serde(rename = ":bun:")] + Bun, + #[serde(rename = ":node:")] + Node, + #[serde(rename = ":types:")] + Types, +} diff --git a/crates/biome_js_analyze/src/utils.rs b/crates/biome_js_analyze/src/utils.rs index 1bd8375a4df1..7c1a4693212a 100644 --- a/crates/biome_js_analyze/src/utils.rs +++ b/crates/biome_js_analyze/src/utils.rs @@ -4,6 +4,7 @@ use std::iter; pub mod batch; pub mod rename; +pub mod restricted_glob; pub mod restricted_regex; #[cfg(test)] pub mod tests; diff --git a/crates/biome_js_analyze/src/utils/restricted_glob.rs b/crates/biome_js_analyze/src/utils/restricted_glob.rs new file mode 100644 index 000000000000..0bbf63422b23 --- /dev/null +++ b/crates/biome_js_analyze/src/utils/restricted_glob.rs @@ -0,0 +1,154 @@ +use biome_deserialize_macros::Deserializable; + +/// A restricted glov pattern only supports the following syntaxes: +/// +/// - star `*` that matches zero or more character inside a path segment +/// - globstar `**` that matches zero or more path segments +/// - Use `\*` to escape `*` +/// - `?`, `[`, `]`, `{`, and `}` must be escaped using `\`. +/// These characters are reserved for future use. +/// - `!` must be escaped if it is the first characrter of the pattern +/// +/// A path segment is delimited by path separator `/` or the start/end of the path. +#[derive(Clone, Debug, Deserializable, serde::Deserialize, serde::Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct RestrictedGlob(globset::GlobMatcher); + +impl std::ops::Deref for RestrictedGlob { + type Target = globset::GlobMatcher; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::fmt::Display for RestrictedGlob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let repr = self.0.glob().to_string(); + f.write_str(&repr) + } +} + +impl From for String { + fn from(value: RestrictedGlob) -> Self { + value.to_string() + } +} + +impl std::str::FromStr for RestrictedGlob { + type Err = globset::ErrorKind; + + fn from_str(value: &str) -> Result { + is_restricted_glob(value)?; + let mut glob_builder = globset::GlobBuilder::new(value); + // Allow escaping with `\` on all platforms. + glob_builder.backslash_escape(true); + // Only `**` can match `/` + glob_builder.literal_separator(true); + match glob_builder.build() { + Ok(glob) => Ok(RestrictedGlob(glob.compile_matcher())), + Err(error) => Err(error.kind().clone()), + } + } +} + +impl TryFrom for RestrictedGlob { + type Error = globset::ErrorKind; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for RestrictedGlob { + fn schema_name() -> String { + "Regex".to_string() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(gen) + } +} + +/// Returns an error if `pattern` doesn't follow the restricted glob syntax. +fn is_restricted_glob(pattern: &str) -> Result<(), globset::ErrorKind> { + let mut it = pattern.bytes().enumerate(); + while let Some((i, c)) = it.next() { + match c { + b'!' if i == 0 => { + return Err(globset::ErrorKind::Regex( + r"Negated globs `!` are not supported. Use `\!` to escape the character." + .to_string(), + )); + } + b'\\' => { + // Accept a restrictive set of escape sequence + if let Some((_, c)) = it.next() { + if !matches!(c, b'!' | b'*' | b'?' | b'{' | b'}' | b'[' | b']' | b'\\') { + // SAFETY: safe because of the match + let c = unsafe { char::from_u32_unchecked(c as u32) }; + // Escape sequences https://docs.rs/regex/latest/regex/#escape-sequences + // and Perl char classes https://docs.rs/regex/latest/regex/#perl-character-classes-unicode-friendly + return Err(globset::ErrorKind::Regex(format!( + "Escape sequence \\{c} is not supported." + ))); + } + } else { + return Err(globset::ErrorKind::DanglingEscape); + } + } + b'?' => { + return Err(globset::ErrorKind::Regex( + r"`?` matcher is not supported. Use `\?` to escape the character.".to_string(), + )); + } + b'[' | b']' => { + return Err(globset::ErrorKind::Regex( + r"Character class `[]` are not supported. Use `\[` and `\]` to escape the characters." + .to_string(), + )); + } + b'{' | b'}' => { + return Err(globset::ErrorKind::Regex( + r"Alternates `{}` are not supported. Use `\{` and `\}` to escape the characters.".to_string(), + )); + } + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_restricted_glob() { + assert!(is_restricted_glob("!*.js").is_err()); + assert!(is_restricted_glob("*.[jt]s").is_err()); + assert!(is_restricted_glob("*.{js,ts}").is_err()); + assert!(is_restricted_glob("?*.js").is_err()); + assert!(is_restricted_glob(r"\").is_err()); + assert!(is_restricted_glob("!").is_err()); + + assert!(is_restricted_glob("*.js").is_ok()); + assert!(is_restricted_glob("**/*.js").is_ok()); + assert!(is_restricted_glob(r"\*").is_ok()); + assert!(is_restricted_glob(r"\!").is_ok()); + } + + #[test] + fn test_restricted_regex() { + assert!(!"*.js" + .parse::() + .unwrap() + .is_match("file/path.js")); + + assert!("**/*.js" + .parse::() + .unwrap() + .is_match("file/path.js")); + } +} diff --git a/crates/biome_js_analyze/src/utils/restricted_regex.rs b/crates/biome_js_analyze/src/utils/restricted_regex.rs index 775c3805cc75..105fb1d16b6f 100644 --- a/crates/biome_js_analyze/src/utils/restricted_regex.rs +++ b/crates/biome_js_analyze/src/utils/restricted_regex.rs @@ -90,7 +90,7 @@ impl PartialEq for RestrictedRegex { } } -/// Rteurns an error if `pattern` doesn't follow the restricted regular expression syntax. +/// Returns an error if `pattern` doesn't follow the restricted regular expression syntax. fn is_restricted_regex(pattern: &str) -> Result<(), regex::Error> { let mut it = pattern.bytes(); let mut is_in_char_class = false; @@ -218,7 +218,7 @@ mod tests { use super::*; #[test] - fn test() { + fn test_is_restricted_regex() { assert!(is_restricted_regex("^a").is_err()); assert!(is_restricted_regex("a$").is_err()); assert!(is_restricted_regex(r"\").is_err());