diff --git a/Cargo.lock b/Cargo.lock index f19c2c75..49baa54b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,16 @@ version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -337,9 +347,11 @@ dependencies = [ "dprint-core-macros", "dprint-development", "dprint-plugin-sql", + "globset", "malva", "markup_fmt", "percent-encoding", + "phf", "pretty_assertions", "rustc-hash 2.1.1", "serde", @@ -421,6 +433,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -655,6 +680,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "malva" version = "0.11.2" diff --git a/Cargo.toml b/Cargo.toml index 1bbec3c3..bce6dd64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ overflow-checks = false panic = "abort" [features] -wasm = ["serde_json", "dprint-core/wasm"] +wasm = ["dprint-core/wasm"] tracing = ["dprint-core/tracing"] [[test]] @@ -35,10 +35,12 @@ capacity_builder = "0.5.0" deno_ast = { version = "0.53.0", features = ["view"] } dprint-core = { version = "0.67.4", features = ["formatting"] } dprint-core-macros = "0.1.0" +globset = "0.4" percent-encoding = "2.3.1" +phf = { version = "0.11", features = ["macros"] } rustc-hash = "2.1.1" serde = { version = "1.0.144", features = ["derive"] } -serde_json = { version = "1.0", optional = true } +serde_json = { version = "1.0" } [dev-dependencies] dprint-development = "0.10.1" diff --git a/README.md b/README.md index a0e254e6..e8026013 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,68 @@ You may wish to try out the plugin by building from source: 1. Run `cargo build --target wasm32-unknown-unknown --release --features "wasm"` 1. Reference the file at `./target/wasm32-unknown-unknown/release/dprint_plugin_typescript.wasm` in a dprint configuration file. + +## Import Grouping + +This plugin can automatically group import declarations into logical sections separated by blank lines, similar to ESLint's `import/order` rule. + +### Quick start + +```jsonc +{ + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" }, + { "match": "parent" }, + { "match": ["sibling", "index"] } + ] +} +``` + +This reorders imports across the import block into the listed groups and inserts exactly one blank line between groups. + +### Options + +| Key | Type | Default | Description | +|---|---|---|---| +| `module.importGroups` | array | `[]` (off) | Ordered list of groups. Empty disables the feature. | +| `module.typeImports` | `"separate"` \| `"interleave"` | `"separate"` | Whether `import type` lines form their own category. | +| `module.builtinsRuntime` | `"node"` \| `"deno"` \| `"bun"` \| `"none"` | `"node"` | Which runtime's built-in module list classifies as `builtin`. | + +### Built-in categories + +`builtin`, `external`, `parent`, `sibling`, `index`, `type`, `unknown`. + +Use a string in `match` for a single category, or an array to merge multiple categories into one group (no blank line between): + +```jsonc +{ "match": ["sibling", "index"] } +``` + +For pattern-based groups, use a glob: + +```jsonc +{ "match": { "pattern": "@app/**" } } +``` + +First-match-wins across the list, so position determines precedence. + +### Migration from ESLint `import/order` + +| ESLint option | dprint equivalent | +|---|---| +| `groups` | `module.importGroups` (strings; nested arrays merge) | +| `pathGroups` | `{ "pattern": "..." }` entries placed positionally | +| `newlines-between: "always"` | Default when feature is enabled | +| `newlines-between: "never"`/`"ignore"` | Set `module.importGroups` to `[]` (feature off) | +| `alphabetize.order: "asc"` | Existing `module.sortImportDeclarations` | +| `alphabetize.order: "desc"` | Not supported | + +### Limitations + +- CommonJS `require(...)` and dynamic `import()` are not reordered. +- Module resolver / tsconfig paths not consulted (raw source string only). +- Descending sort not supported. +- TS `import X = require(...)` not reordered. +- Imports inside nested `declare module "..."` bodies are not classified. +- Currently, an import with `// dprint-ignore` is reordered like any other; barrier treatment is planned for a follow-up. diff --git a/deployment/schema.json b/deployment/schema.json index 13e5b70d..98d91c95 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -1027,6 +1027,52 @@ "module.sortExportDeclarations": { "$ref": "#/definitions/sortOrder" }, + "module.importGroups": { + "description": "Ordered list of import groups, separated by blank lines. Empty disables the feature.", + "type": "array", + "default": [], + "items": { + "type": "object", + "properties": { + "match": { + "oneOf": [ + { + "type": "string", + "enum": ["builtin", "external", "parent", "sibling", "index", "type", "unknown"] + }, + { + "type": "object", + "required": ["pattern"], + "properties": { + "pattern": { "type": "string" } + } + }, + { + "type": "array", + "items": { + "oneOf": [ + { "type": "string", "enum": ["builtin", "external", "parent", "sibling", "index", "type", "unknown"] }, + { "type": "object", "required": ["pattern"], "properties": { "pattern": { "type": "string" } } } + ] + } + } + ] + } + } + } + }, + "module.typeImports": { + "description": "How type-only imports are classified by module.importGroups.", + "type": "string", + "default": "separate", + "enum": ["separate", "interleave"] + }, + "module.builtinsRuntime": { + "description": "Which runtime's built-in modules count as `builtin`.", + "type": "string", + "default": "node", + "enum": ["node", "deno", "bun", "none"] + }, "exportDeclaration.sortNamedExports": { "$ref": "#/definitions/sortOrder" }, diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs index bfa35df5..57b33d1f 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -576,6 +576,29 @@ impl ConfigurationBuilder { self.insert("exportDeclaration.sortTypeOnlyExports", value.to_string().into()) } + /// Ordered groups for `module.importGroups`. Empty = feature disabled. + /// + /// Default: `[]` + pub fn module_import_groups(&mut self, value: Vec) -> &mut Self { + let json = serde_json::to_value(value).unwrap(); + let cfg_val: dprint_core::configuration::ConfigKeyValue = serde_json::from_value(json).unwrap(); + self.insert("module.importGroups", cfg_val) + } + + /// How type-only imports are classified. + /// + /// Default: `Separate` + pub fn module_type_imports(&mut self, value: TypeImportsMode) -> &mut Self { + self.insert("module.typeImports", value.to_string().into()) + } + + /// Which runtime's built-in modules count as `builtin`. + /// + /// Default: `Node` + pub fn module_builtins_runtime(&mut self, value: BuiltinsRuntime) -> &mut Self { + self.insert("module.builtinsRuntime", value.to_string().into()) + } + /* ignore comments */ /// The text to use for an ignore comment (ex. `// dprint-ignore`). diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index ea393be3..a5cec48c 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -132,6 +132,9 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) NamedTypeImportsExportsOrder::None, &mut diagnostics, ), + module_import_groups: parse_import_groups(&mut config, &mut diagnostics), + module_type_imports: get_value(&mut config, "module.typeImports", TypeImportsMode::Separate, &mut diagnostics), + module_builtins_runtime: get_value(&mut config, "module.builtinsRuntime", BuiltinsRuntime::Node, &mut diagnostics), /* ignore comments */ ignore_node_comment_text: get_value(&mut config, "ignoreNodeCommentText", String::from("dprint-ignore"), &mut diagnostics), ignore_file_comment_text: get_value(&mut config, "ignoreFileCommentText", String::from("dprint-ignore-file"), &mut diagnostics), @@ -338,6 +341,17 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) diagnostics.extend(get_unknown_property_diagnostics(config)); + if !resolved_config.module_import_groups.is_empty() { + let mut compile_diags: Vec = Vec::new(); + let _ = crate::generation::imports::resolved::compile(&resolved_config, &mut compile_diags); + for msg in compile_diags { + diagnostics.push(ConfigurationDiagnostic { + property_name: "module.importGroups".to_string(), + message: msg, + }); + } + } + return ResolveConfigurationResult { config: resolved_config, diagnostics, @@ -352,6 +366,35 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) } } +fn parse_import_groups( + config: &mut ConfigKeyMap, + diagnostics: &mut Vec, +) -> Vec { + let Some(raw) = config.shift_remove("module.importGroups") else { + return Vec::new(); + }; + let json = match serde_json::to_value(&raw) { + Ok(v) => v, + Err(err) => { + diagnostics.push(ConfigurationDiagnostic { + property_name: "module.importGroups".to_string(), + message: format!("Failed to convert config value to JSON: {err}"), + }); + return Vec::new(); + } + }; + match serde_json::from_value::>(json) { + Ok(groups) => groups, + Err(err) => { + diagnostics.push(ConfigurationDiagnostic { + property_name: "module.importGroups".to_string(), + message: format!("Invalid import groups configuration: {err}"), + }); + Vec::new() + } + } +} + #[cfg(test)] mod tests { use dprint_core::configuration::NewLineKind; @@ -412,3 +455,76 @@ mod tests { assert_eq!(result.diagnostics.len(), 0); } } + +#[cfg(test)] +mod import_groups_resolution_tests { + use super::*; + use dprint_core::configuration::ConfigKeyMap; + + fn resolve(json: serde_json::Value) -> ResolveConfigurationResult { + let map: ConfigKeyMap = serde_json::from_value(json).unwrap(); + resolve_config(map, &Default::default()) + } + + #[test] + fn empty_import_groups_default() { + let r = resolve(serde_json::json!({})); + assert!(r.config.module_import_groups.is_empty()); + assert!(matches!(r.config.module_type_imports, TypeImportsMode::Separate)); + assert!(matches!(r.config.module_builtins_runtime, BuiltinsRuntime::Node)); + assert!(r.diagnostics.is_empty()); + } + + #[test] + fn parses_basic_eslint_mirror() { + let r = resolve(serde_json::json!({ + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" }, + { "match": ["sibling", "index"] } + ] + })); + assert!(r.diagnostics.is_empty(), "unexpected diagnostics: {:?}", r.diagnostics.iter().map(|d| &d.message).collect::>()); + assert_eq!(r.config.module_import_groups.len(), 3); + } + + #[test] + fn invalid_import_groups_emits_diagnostic() { + let r = resolve(serde_json::json!({ + "module.importGroups": "not-an-array" + })); + assert_eq!(r.config.module_import_groups.len(), 0); + assert_eq!(r.diagnostics.len(), 1); + assert_eq!(r.diagnostics[0].property_name, "module.importGroups"); + } + + #[test] + fn unknown_category_string_diagnostic() { + let r = resolve(serde_json::json!({ + "module.importGroups": [{ "match": "buildin" }] + })); + assert!(!r.diagnostics.is_empty(), "expected diagnostic for typo"); + assert!( + r.diagnostics.iter().any(|d| { + let m = d.message.to_lowercase(); + m.contains("buildin") + || m.contains("unknown") + || m.contains("did not match any variant") + || m.contains("invalid import groups") + }), + "diagnostic should signal an invalid variant, got: {:?}", + r.diagnostics.iter().map(|d| &d.message).collect::>() + ); + } + + #[test] + fn duplicate_category_surfaces_via_resolve_config() { + let r = resolve(serde_json::json!({ + "module.importGroups": [ + { "match": "builtin" }, + { "match": "builtin" } + ] + })); + assert!(r.diagnostics.iter().any(|d| d.property_name == "module.importGroups" && d.message.contains("Builtin")), "expected duplicate-category diagnostic, got: {:?}", r.diagnostics.iter().map(|d| &d.message).collect::>()); + } +} diff --git a/src/configuration/types.rs b/src/configuration/types.rs index 61b08bf7..24bef29c 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -307,6 +307,88 @@ pub enum NamedTypeImportsExportsOrder { generate_str_to_from![NamedTypeImportsExportsOrder, [First, "first"], [Last, "last"], [None, "none"]]; +/// How type-only imports are classified by `module.importGroups`. +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TypeImportsMode { + /// Type-only imports form a distinct implicit category `type`. + Separate, + /// Type-only imports are classified by source path like value imports. + Interleave, +} + +generate_str_to_from![TypeImportsMode, [Separate, "separate"], [Interleave, "interleave"]]; + +/// Which runtime's built-in modules count as `builtin` for grouping. +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BuiltinsRuntime { + /// `node:` prefix or Node core module list (default). + Node, + /// `node:` prefix only. + Deno, + /// `node:` prefix, `bun:` prefix, or Node core module list. + Bun, + /// Nothing matches `builtin`. + None, +} + +generate_str_to_from![ + BuiltinsRuntime, + [Node, "node"], + [Deno, "deno"], + [Bun, "bun"], + [None, "none"] +]; + +/// Built-in category strings allowed in `module.importGroups[].match`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BuiltinCategory { + Builtin, + External, + Parent, + Sibling, + Index, + Type, + Unknown, +} + +generate_str_to_from![ + BuiltinCategory, + [Builtin, "builtin"], + [External, "external"], + [Parent, "parent"], + [Sibling, "sibling"], + [Index, "index"], + [Type, "type"], + [Unknown, "unknown"] +]; + +/// A single matcher inside a group's `match` value. +#[derive(Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImportMatcher { + Category(BuiltinCategory), + /// Raw glob pattern string. Compiled lazily by resolve_config into a globset. + Pattern { pattern: String }, +} + +/// Either a single matcher or a list (list = merged into one group). +#[derive(Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImportGroupMatch { + Single(ImportMatcher), + Multiple(Vec), +} + +/// One resolved import group, in user-listed order. +#[derive(Clone, Serialize, Deserialize)] +pub struct ImportGroup { + #[serde(rename = "match")] + pub matchers: ImportGroupMatch, +} + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Configuration { @@ -354,6 +436,12 @@ pub struct Configuration { pub export_declaration_sort_named_exports: SortOrder, #[serde(rename = "exportDeclaration.sortTypeOnlyExports")] pub export_declaration_sort_type_only_exports: NamedTypeImportsExportsOrder, + #[serde(rename = "module.importGroups", default, skip_serializing_if = "Vec::is_empty")] + pub module_import_groups: Vec, + #[serde(rename = "module.typeImports", default = "default_type_imports_mode")] + pub module_type_imports: TypeImportsMode, + #[serde(rename = "module.builtinsRuntime", default = "default_builtins_runtime")] + pub module_builtins_runtime: BuiltinsRuntime, /* ignore comments */ pub ignore_node_comment_text: String, pub ignore_file_comment_text: String, @@ -665,3 +753,52 @@ pub struct Configuration { #[serde(rename = "whileStatement.spaceAround")] pub while_statement_space_around: bool, } + +fn default_type_imports_mode() -> TypeImportsMode { + TypeImportsMode::Separate +} + +fn default_builtins_runtime() -> BuiltinsRuntime { + BuiltinsRuntime::Node +} + +#[cfg(test)] +mod import_group_enum_tests { + use super::*; + use std::str::FromStr; + + #[test] + fn type_imports_mode_round_trip() { + assert!(matches!(TypeImportsMode::from_str("separate"), Ok(TypeImportsMode::Separate))); + assert!(matches!(TypeImportsMode::from_str("interleave"), Ok(TypeImportsMode::Interleave))); + assert_eq!(TypeImportsMode::Separate.to_string(), "separate"); + } + + #[test] + fn builtins_runtime_round_trip() { + assert!(matches!(BuiltinsRuntime::from_str("node"), Ok(BuiltinsRuntime::Node))); + assert!(matches!(BuiltinsRuntime::from_str("deno"), Ok(BuiltinsRuntime::Deno))); + assert!(matches!(BuiltinsRuntime::from_str("bun"), Ok(BuiltinsRuntime::Bun))); + assert!(matches!(BuiltinsRuntime::from_str("none"), Ok(BuiltinsRuntime::None))); + assert_eq!(BuiltinsRuntime::Node.to_string(), "node"); + } + + #[test] + fn import_matcher_variants() { + let c = ImportMatcher::Category(BuiltinCategory::External); + let p = ImportMatcher::Pattern { pattern: "foo/*".to_string() }; + assert!(matches!(c, ImportMatcher::Category(BuiltinCategory::External))); + assert!(matches!(p, ImportMatcher::Pattern { pattern: _ })); + } + + #[test] + fn builtin_category_round_trip() { + assert!(matches!(BuiltinCategory::from_str("builtin"), Ok(BuiltinCategory::Builtin))); + assert!(matches!(BuiltinCategory::from_str("external"), Ok(BuiltinCategory::External))); + assert!(matches!(BuiltinCategory::from_str("parent"), Ok(BuiltinCategory::Parent))); + assert!(matches!(BuiltinCategory::from_str("sibling"), Ok(BuiltinCategory::Sibling))); + assert!(matches!(BuiltinCategory::from_str("index"), Ok(BuiltinCategory::Index))); + assert!(matches!(BuiltinCategory::from_str("type"), Ok(BuiltinCategory::Type))); + assert!(matches!(BuiltinCategory::from_str("unknown"), Ok(BuiltinCategory::Unknown))); + } +} diff --git a/src/generation/context.rs b/src/generation/context.rs index 2f50f4c5..4365a290 100644 --- a/src/generation/context.rs +++ b/src/generation/context.rs @@ -76,7 +76,10 @@ pub struct Context<'a> { /// Used for ensuring nodes are parsed in order. #[cfg(debug_assertions)] pub last_generated_node_pos: SourcePos, + #[cfg(debug_assertions)] + pub bypass_node_order_check: bool, pub diagnostics: Vec, + pub resolved_import_groups: Option, } impl<'a> Context<'a> { @@ -88,6 +91,8 @@ impl<'a> Context<'a> { config: &'a Configuration, external_formatter: Option<&'a ExternalFormatter>, ) -> Context<'a> { + let mut _import_group_diags: Vec = Vec::new(); + let resolved_import_groups = crate::generation::imports::resolved::compile(config, &mut _import_group_diags); Context { media_type, program, @@ -110,7 +115,10 @@ impl<'a> Context<'a> { expr_stmt_single_line_parent_brace_ref: None, #[cfg(debug_assertions)] last_generated_node_pos: deno_ast::SourceTextInfoProvider::text_info(&program).range().start.into(), + #[cfg(debug_assertions)] + bypass_node_order_check: false, diagnostics: Vec::new(), + resolved_import_groups, } } diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 7fb3a332..15d53fa6 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -391,6 +391,9 @@ fn gen_node_with_inner_gen<'a>(node: Node<'a>, context: &mut Context<'a>, inner_ #[cfg(debug_assertions)] fn assert_generated_in_order(node: Node, context: &mut Context) { + if context.bypass_node_order_check { + return; + } let node_pos = node.start(); if context.last_generated_node_pos > node_pos { // When this panic happens it means that a node with a start further @@ -6747,6 +6750,35 @@ fn gen_comments_as_statements<'a>(comments: impl Iterator, l items } +/// Like `gen_comments_as_statements` but emits comments even if they are +/// already marked as handled. Used by the import-groups feature to emit +/// pre-captured comments after marking them handled up-front (so that the +/// per-node sweep in `gen_node` skips them). +fn gen_captured_comments_as_statements<'a>(comments: &[&'a Comment], last_node: Option<&SourceRange>, context: &mut Context<'a>) -> PrintItems { + let mut last_node = last_node.map(|l| l.range()); + let mut items = PrintItems::new(); + let mut was_last_block_comment = false; + for comment in comments { + // Emit even though already-handled. `gen_comment_based_on_last_node` -> `gen_comment` + // would short-circuit on handled, so call the renderer directly. + if let Some(last_node) = &last_node { + let comment_start_line = comment.start_line_fast(context.program); + let last_node_end_line = last_node.end_line_fast(context.program); + items.push_signal(Signal::NewLine); + if comment_start_line > last_node_end_line + 1 { + items.push_signal(Signal::NewLine); + } + } + items.extend(render_comment(comment, context)); + last_node = Some(comment.range()); + was_last_block_comment = comment.kind == CommentKind::Block; + } + if was_last_block_comment { + items.push_signal(Signal::ExpectNewLine); + } + items +} + fn gen_comments_between_lines_indented(start_between_pos: SourcePos, context: &mut Context) -> PrintItems { let trailing_comments = get_comments_between_lines(start_between_pos, context); let mut items = PrintItems::new(); @@ -6929,8 +6961,14 @@ fn gen_comment(comment: &Comment, context: &mut Context) -> Option { // mark handled and generate context.mark_comment_handled(comment); + Some(render_comment(comment, context)) +} - return Some(match comment.kind { +/// Render a comment's text without consulting the handled-set. Callers use +/// this when they need to emit a comment that is already marked handled +/// (e.g. when the import-groups feature pre-captures comments). +fn render_comment(comment: &Comment, context: &mut Context) -> PrintItems { + match comment.kind { CommentKind::Block => { if has_leading_astrisk_each_line(&comment.text) { gen_js_doc_or_multiline_block(comment, context) @@ -6940,22 +6978,22 @@ fn gen_comment(comment: &Comment, context: &mut Context) -> Option { } } CommentKind::Line => ir_helpers::gen_js_like_comment_line(&comment.text, context.config.comment_line_force_space_after_slashes), - }); + } +} - fn has_leading_astrisk_each_line(text: &str) -> bool { - if !text.contains('\n') { - return false; - } +fn has_leading_astrisk_each_line(text: &str) -> bool { + if !text.contains('\n') { + return false; + } - for line in text.trim().split('\n') { - let first_non_whitespace = line.trim_start().chars().next(); - if !matches!(first_non_whitespace, Some('*')) { - return false; - } + for line in text.trim().split('\n') { + let first_non_whitespace = line.trim_start().chars().next(); + if !matches!(first_non_whitespace, Some('*')) { + return false; } - - true } + + true } fn gen_js_doc_or_multiline_block(comment: &Comment, _context: &mut Context) -> PrintItems { @@ -7283,7 +7321,20 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & let stmt_group_len = stmt_groups.len(); for (stmt_group_index, stmt_group) in stmt_groups.into_iter().enumerate() { - if stmt_group.kind == StmtGroupKind::Imports || stmt_group.kind == StmtGroupKind::Exports { + if stmt_group.subgroup_boundaries.is_some() { + // Imports were reordered. Emit the detached file-header comments pinned + // to source position; per-node attached comments are emitted inside the + // loop below so they follow their import. + if !stmt_group.captured_detached_header.is_empty() { + let last_comment = stmt_group.captured_detached_header.last().map(|c| c.range()); + items.extend(gen_captured_comments_as_statements( + &stmt_group.captured_detached_header, + last_node.as_ref().map(|x| x as &SourceRange), + context, + )); + last_node = last_comment.or(last_node); + } + } else if stmt_group.kind == StmtGroupKind::Imports || stmt_group.kind == StmtGroupKind::Exports { // keep the leading comments of the stmt group on the same line let comments = get_leading_comments_on_previous_lines(&stmt_group.nodes.first().as_ref().unwrap().start().range(), context); let last_comment = comments.iter().filter(|c| !context.has_handled_comment(c)).last().map(|c| c.range()); @@ -7298,18 +7349,41 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & let nodes_len = stmt_group.nodes.len(); let mut generated_nodes = Vec::with_capacity(nodes_len); let mut generated_line_separators = utils::VecMap::with_capacity(nodes_len); - let sorter = get_node_sorter(stmt_group.kind, context); + let sorter = if stmt_group.subgroup_boundaries.is_some() { + None + } else { + get_node_sorter(stmt_group.kind, context) + }; let sorted_indexes = match sorter { Some(sorter) => Some(get_sorted_indexes(stmt_group.nodes.iter().map(|n| Some(*n)), sorter, context)), None => None, }; + let subgroup_boundary_set: rustc_hash::FxHashSet = stmt_group + .subgroup_boundaries + .as_ref() + .map(|bs| bs.iter().copied().collect()) + .unwrap_or_default(); + let has_subgroup_boundaries = stmt_group.subgroup_boundaries.is_some(); + #[cfg(debug_assertions)] + let max_node_pos = stmt_group.nodes.iter().map(|n| n.start()).max(); + #[cfg(debug_assertions)] + let prev_bypass = context.bypass_node_order_check; + #[cfg(debug_assertions)] + if has_subgroup_boundaries { + context.bypass_node_order_check = true; + } for (i, node) in stmt_group.nodes.into_iter().enumerate() { let is_empty_stmt = node.is::(); if !is_empty_stmt { let mut separator_items = PrintItems::new(); if let Some(last_node) = &last_node { separator_items.push_signal(Signal::NewLine); - if node_helpers::has_separating_blank_line(&last_node, &node, context.program) { + let blank_line = if has_subgroup_boundaries { + subgroup_boundary_set.contains(&i) + } else { + node_helpers::has_separating_blank_line(&last_node, &node, context.program) + }; + if blank_line { separator_items.push_signal(Signal::NewLine); } generated_line_separators.insert(i, separator_items); @@ -7318,6 +7392,16 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & let mut items = PrintItems::new(); let end_ln = LineNumber::new("endStatement"); context.end_statement_or_member_lns.push(end_ln); + // Emit captured attached leading comments so they travel with this + // import even after reorder. The list was permuted to post-reorder + // order at partition time so we can index directly. + if has_subgroup_boundaries { + if let Some(comments) = stmt_group.captured_attached_leading.get(i) { + if !comments.is_empty() { + items.extend(gen_captured_comments_as_statements(comments, None, context)); + } + } + } items.extend(gen_node(node, context)); items.push_info(end_ln); generated_nodes.push(items); @@ -7344,6 +7428,17 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & } } } + #[cfg(debug_assertions)] + if has_subgroup_boundaries { + context.bypass_node_order_check = prev_bypass; + // Advance `last_generated_node_pos` to the max source position among + // emitted nodes so subsequent statements pass the order check. + if let Some(p) = max_node_pos { + if p > context.last_generated_node_pos { + context.last_generated_node_pos = p; + } + } + } // Get the generated statements/members sorted let generated_nodes = match sorted_indexes { @@ -7411,6 +7506,26 @@ enum StmtGroupKind { struct StmtGroup<'a> { kind: StmtGroupKind, nodes: Vec>, + /// Indices into `nodes` (post-reorder) marking the start of each subgroup. + /// Only Some for `StmtGroupKind::Imports` when `module.importGroups` is non-empty. + subgroup_boundaries: Option>, + /// Per source-index attached leading comments captured before reorder. Each + /// entry holds the comments that should "travel" with the import at that + /// original source index. Only populated when imports are reordered. + captured_attached_leading: Vec>, + /// Detached comments above the FIRST import in source order (e.g. file + /// header / license). These stay pinned to the file start and are emitted + /// before the per-node loop. + captured_detached_header: Vec<&'a Comment>, +} + +fn node_src_with_quotes<'a>(node: &Node<'a>, context: &Context<'a>) -> String { + if let Node::ImportDecl(d) = node { + // cmp_module_specifiers wants text including surrounding quotes. + d.src.text_fast(context.program).to_string() + } else { + String::new() + } } fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec> { @@ -7441,12 +7556,18 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec = g + .nodes + .iter() + .enumerate() + .map(|(i, node)| { + let (src, is_type) = if let Node::ImportDecl(d) = node { + (d.src.value().as_str().unwrap_or("").to_string(), d.type_only()) + } else { + (String::new(), false) + }; + let idx = crate::generation::imports::classify::classify( + &src, + is_type, + context.config.module_type_imports, + context.config.module_builtins_runtime, + resolved, + ); + (idx, i) + }) + .collect(); + // Pre-collect source strings to avoid borrowing g.nodes inside the closure. + let node_srcs: Vec = g.nodes.iter().map(|n| node_src_with_quotes(n, context)).collect(); + + let sort = context.config.module_sort_import_declarations; + let (ordered, boundaries) = crate::generation::imports::partition::partition_indices( + &classified, + resolved.groups.len(), + |a_orig: usize, b_orig: usize| -> std::cmp::Ordering { + use crate::configuration::SortOrder; + use crate::generation::sorting::module_specifiers::cmp_module_specifiers; + if matches!(sort, SortOrder::Maintain) { + return a_orig.cmp(&b_orig); + } + match sort { + SortOrder::CaseSensitive => cmp_module_specifiers(&node_srcs[a_orig], &node_srcs[b_orig], |x, y| x.cmp(y)), + SortOrder::CaseInsensitive => cmp_module_specifiers(&node_srcs[a_orig], &node_srcs[b_orig], crate::generation::sorting::cmp_text_case_insensitive), + SortOrder::Maintain => unreachable!(), + } + }, + ); + + // Capture per-node leading comments BEFORE the reorder mutation so that + // comments physically attached to each import travel with that import. + // The first import's preamble is further split into detached (header) + // and attached portions; only the attached portion travels. + let mut captured_attached_leading: Vec> = Vec::with_capacity(g.nodes.len()); + let mut captured_detached_header: Vec<&Comment> = Vec::new(); + for (src_idx, node) in g.nodes.iter().enumerate() { + let comments: Vec<&Comment> = node.leading_comments_fast(context.program).collect(); + if src_idx == 0 { + // Split into detached header vs attached preamble. A comment is part + // of the "attached" preamble iff there is no blank-line gap between + // it and the node start AND no blank-line gap between it and any + // following attached comment. We walk from the node upward. + let node_start_line = node.start_line_fast(context.program); + let mut attached_start = comments.len(); + let mut next_line = node_start_line; + for i in (0..comments.len()).rev() { + let c = comments[i]; + let c_end_line = c.end_line_fast(context.program); + // Blank line between this comment's end and the next anchor line? + if next_line > c_end_line + 1 { + break; + } + attached_start = i; + next_line = c.start_line_fast(context.program); + } + captured_detached_header.extend(comments[..attached_start].iter().copied()); + captured_attached_leading.push(comments[attached_start..].to_vec()); + } else { + // For non-first nodes, all leading comments travel with the node. + captured_attached_leading.push(comments); + } + } + + // Mark all captured comments handled up-front so the per-node sweep in + // `gen_node` (which uses source positions) skips them. We then emit + // them ourselves via `gen_captured_comments_as_statements`, which + // bypasses the handled-check. + for c in &captured_detached_header { + context.mark_comment_handled(c); + } + for cs in &captured_attached_leading { + for c in cs { + context.mark_comment_handled(c); + } + } + + let mut new_nodes: Vec = Vec::with_capacity(ordered.len()); + let mut reordered_attached: Vec> = Vec::with_capacity(ordered.len()); + for orig in &ordered { + new_nodes.push(g.nodes[*orig]); + reordered_attached.push(std::mem::take(&mut captured_attached_leading[*orig])); + } + g.nodes = new_nodes; + g.subgroup_boundaries = Some(boundaries); + g.captured_attached_leading = reordered_attached; + g.captured_detached_header = captured_detached_header; + } + } + context.resolved_import_groups = resolved_opt; + groups } diff --git a/src/generation/imports/classify.rs b/src/generation/imports/classify.rs new file mode 100644 index 00000000..93a1d907 --- /dev/null +++ b/src/generation/imports/classify.rs @@ -0,0 +1,165 @@ +//! Pure classification of an import declaration into one of the resolved groups. + +use crate::configuration::{BuiltinCategory, BuiltinsRuntime, TypeImportsMode}; +use crate::generation::imports::resolved::ResolvedGroups; +use crate::utils::builtins::is_builtin; + +/// Classify a single import: return the index in `resolved.groups`. +pub fn classify( + src: &str, + is_type_only: bool, + type_imports_mode: TypeImportsMode, + builtins_runtime: BuiltinsRuntime, + resolved: &ResolvedGroups, +) -> usize { + let category = base_category(src, is_type_only, type_imports_mode, builtins_runtime); + for (i, g) in resolved.groups.iter().enumerate() { + if g.categories.contains(&category) { + return i; + } + if g.has_globs && g.globs.is_match(src) { + return i; + } + } + resolved.unknown_index +} + +fn base_category( + src: &str, + is_type_only: bool, + type_imports_mode: TypeImportsMode, + builtins_runtime: BuiltinsRuntime, +) -> BuiltinCategory { + if is_type_only && matches!(type_imports_mode, TypeImportsMode::Separate) { + return BuiltinCategory::Type; + } + if is_builtin(src, builtins_runtime) { + return BuiltinCategory::Builtin; + } + if src.starts_with("../") || src == ".." { + return BuiltinCategory::Parent; + } + if is_index_path(src) { + return BuiltinCategory::Index; + } + if src.starts_with("./") { + return BuiltinCategory::Sibling; + } + BuiltinCategory::External +} + +fn is_index_path(src: &str) -> bool { + matches!( + src, + "." | "./" | "./index" + | "./index.ts" | "./index.tsx" + | "./index.js" | "./index.jsx" + | "./index.mjs" | "./index.cjs" + | "./index.mts" | "./index.cts" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::configuration::*; + use crate::generation::imports::resolved::compile; + use dprint_core::configuration::ConfigKeyMap; + + fn classify_with(json: serde_json::Value, src: &str, is_type: bool) -> usize { + let map: ConfigKeyMap = serde_json::from_value(json).unwrap(); + let cfg = resolve_config(map, &Default::default()).config; + let mut diags = Vec::new(); + let r = compile(&cfg, &mut diags).unwrap(); + classify(src, is_type, cfg.module_type_imports, cfg.module_builtins_runtime, &r) + } + + fn eslint_mirror() -> serde_json::Value { + serde_json::json!({ + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" }, + { "match": "parent" }, + { "match": ["sibling", "index"] } + ] + }) + } + + #[test] + fn builtins_first() { + assert_eq!(classify_with(eslint_mirror(), "fs", false), 0); + assert_eq!(classify_with(eslint_mirror(), "node:path", false), 0); + } + + #[test] + fn external_second() { + assert_eq!(classify_with(eslint_mirror(), "react", false), 1); + assert_eq!(classify_with(eslint_mirror(), "@scope/pkg", false), 1); + } + + #[test] + fn parent_third() { + assert_eq!(classify_with(eslint_mirror(), "../a", false), 2); + assert_eq!(classify_with(eslint_mirror(), "../../b", false), 2); + } + + #[test] + fn sibling_and_index_share_fourth() { + assert_eq!(classify_with(eslint_mirror(), "./a", false), 3); + assert_eq!(classify_with(eslint_mirror(), "./index", false), 3); + assert_eq!(classify_with(eslint_mirror(), ".", false), 3); + assert_eq!(classify_with(eslint_mirror(), "./index.ts", false), 3); + } + + #[test] + fn unmatched_goes_to_implicit_unknown() { + let cfg = serde_json::json!({ + "module.importGroups": [{ "match": "builtin" }] + }); + assert_eq!(classify_with(cfg, "react", false), 1); + } + + #[test] + fn type_separate_routes_to_type_group() { + let cfg = serde_json::json!({ + "module.importGroups": [ + { "match": "external" }, + { "match": "type" } + ] + }); + assert_eq!(classify_with(cfg.clone(), "react", false), 0); + assert_eq!(classify_with(cfg, "react", true), 1); + } + + #[test] + fn type_interleave_classifies_by_path() { + let cfg = serde_json::json!({ + "module.importGroups": [{ "match": "external" }], + "module.typeImports": "interleave" + }); + assert_eq!(classify_with(cfg, "react", true), 0); + } + + #[test] + fn pattern_glob_first_match_wins() { + let cfg = serde_json::json!({ + "module.importGroups": [ + { "match": "external" }, + { "match": { "pattern": "@app/**" } } + ] + }); + assert_eq!(classify_with(cfg, "@app/foo", false), 0); + } + + #[test] + fn pattern_glob_before_external() { + let cfg = serde_json::json!({ + "module.importGroups": [ + { "match": { "pattern": "@app/**" } }, + { "match": "external" } + ] + }); + assert_eq!(classify_with(cfg.clone(), "@app/foo", false), 0); + assert_eq!(classify_with(cfg, "react", false), 1); + } +} diff --git a/src/generation/imports/mod.rs b/src/generation/imports/mod.rs new file mode 100644 index 00000000..63a114ad --- /dev/null +++ b/src/generation/imports/mod.rs @@ -0,0 +1,3 @@ +pub mod classify; +pub mod partition; +pub mod resolved; diff --git a/src/generation/imports/partition.rs b/src/generation/imports/partition.rs new file mode 100644 index 00000000..33fcac6f --- /dev/null +++ b/src/generation/imports/partition.rs @@ -0,0 +1,70 @@ +//! Stable partition of an import-group's nodes by classified group index. + +/// Given a list of (group_index, original_index) pairs, return a new ordering +/// of original indices that: +/// 1. groups items by group_index (in ascending order of group_index), +/// 2. within each group, sorts using `cmp_within_group` (stable), +/// 3. records the start index of each non-empty group as a boundary. +/// +/// `cmp_within_group` may return `Equal` to mean "preserve source order". +pub fn partition_indices( + classified: &[(usize, usize)], // (group_index, original_index) + num_groups: usize, + mut cmp_within_group: F, +) -> (Vec, Vec) +where + F: FnMut(usize, usize) -> std::cmp::Ordering, +{ + let mut buckets: Vec> = (0..num_groups).map(|_| Vec::new()).collect(); + for &(g, orig) in classified { + buckets[g].push(orig); + } + + for b in buckets.iter_mut() { + b.sort_by(|&a, &b| cmp_within_group(a, b)); + } + + let mut ordered = Vec::with_capacity(classified.len()); + let mut boundaries = Vec::new(); + for b in buckets.into_iter() { + if b.is_empty() { + continue; + } + boundaries.push(ordered.len()); + ordered.extend(b); + } + (ordered, boundaries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cmp::Ordering; + + fn equal(_a: usize, _b: usize) -> Ordering { + Ordering::Equal + } + + #[test] + fn three_groups_preserves_within_group_order_when_equal() { + let input = vec![(0, 0), (2, 1), (1, 2), (0, 3), (2, 4)]; + let (ordered, boundaries) = partition_indices(&input, 3, equal); + assert_eq!(ordered, vec![0, 3, 2, 1, 4]); + assert_eq!(boundaries, vec![0, 2, 3]); + } + + #[test] + fn empty_buckets_omitted_from_boundaries() { + let input = vec![(0, 0), (2, 1)]; + let (ordered, boundaries) = partition_indices(&input, 3, equal); + assert_eq!(ordered, vec![0, 1]); + assert_eq!(boundaries, vec![0, 1]); + } + + #[test] + fn within_group_sort_applies() { + let input = vec![(0, 0), (0, 1), (0, 2)]; + let (ordered, _) = partition_indices(&input, 1, |a, b| b.cmp(&a)); + assert_eq!(ordered, vec![2, 1, 0]); + } +} diff --git a/src/generation/imports/resolved.rs b/src/generation/imports/resolved.rs new file mode 100644 index 00000000..4ce457b4 --- /dev/null +++ b/src/generation/imports/resolved.rs @@ -0,0 +1,152 @@ +//! Compiled form of `module.importGroups` ready for fast classification. + +use globset::{Glob, GlobSet, GlobSetBuilder}; + +use crate::configuration::{BuiltinCategory, Configuration, ImportGroupMatch, ImportMatcher, TypeImportsMode}; + +/// One resolved group: a set of categories + a glob set, in user-listed order. +pub struct ResolvedGroup { + pub categories: Vec, + pub globs: GlobSet, + pub has_globs: bool, +} + +/// Result of compiling `module.importGroups` into matcher-friendly form. +pub struct ResolvedGroups { + pub groups: Vec, + /// Index into `groups` that catches imports matching no listed category. + pub unknown_index: usize, +} + +/// Compile config's `module.importGroups` into resolved form. Returns `None` +/// when the feature is disabled (empty list). Appends diagnostic strings on +/// duplicate categories or invalid globs. +pub fn compile(config: &Configuration, diagnostics: &mut Vec) -> Option { + if config.module_import_groups.is_empty() { + return None; + } + + let interleave_mode = matches!(config.module_type_imports, TypeImportsMode::Interleave); + + let mut groups: Vec = Vec::new(); + let mut explicit_unknown: Option = None; + let mut seen_categories: std::collections::HashSet = Default::default(); + + for (i, group) in config.module_import_groups.iter().enumerate() { + let matchers = match &group.matchers { + ImportGroupMatch::Single(m) => std::slice::from_ref(m), + ImportGroupMatch::Multiple(v) => v.as_slice(), + }; + + let mut categories = Vec::new(); + let mut builder = GlobSetBuilder::new(); + let mut has_globs = false; + + for m in matchers { + match m { + ImportMatcher::Category(c) => { + if *c == BuiltinCategory::Type && interleave_mode { + diagnostics.push(format!( + "module.importGroups: category \"type\" is ignored under module.typeImports=\"interleave\"." + )); + continue; + } + if !seen_categories.insert(*c) { + diagnostics.push(format!( + "module.importGroups: category {c:?} listed more than once; using first occurrence." + )); + continue; + } + if *c == BuiltinCategory::Unknown { + explicit_unknown = Some(i); + } + categories.push(*c); + } + ImportMatcher::Pattern { pattern } => match Glob::new(pattern) { + Ok(g) => { + builder.add(g); + has_globs = true; + } + Err(e) => diagnostics.push(format!("module.importGroups: invalid glob `{pattern}`: {e}")), + }, + } + } + + groups.push(ResolvedGroup { + categories, + globs: builder.build().unwrap_or_else(|_| GlobSet::empty()), + has_globs, + }); + } + + let unknown_index = match explicit_unknown { + Some(i) => i, + None => { + groups.push(ResolvedGroup { + categories: vec![BuiltinCategory::Unknown], + globs: GlobSet::empty(), + has_globs: false, + }); + groups.len() - 1 + } + }; + + Some(ResolvedGroups { groups, unknown_index }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::configuration::*; + use dprint_core::configuration::ConfigKeyMap; + + fn build(json: serde_json::Value) -> Configuration { + let map: ConfigKeyMap = serde_json::from_value(json).unwrap(); + let r = resolve_config(map, &Default::default()); + r.config + } + + #[test] + fn empty_returns_none() { + let cfg = build(serde_json::json!({})); + let mut diags = Vec::new(); + assert!(compile(&cfg, &mut diags).is_none()); + } + + #[test] + fn appends_implicit_unknown_at_end() { + let cfg = build(serde_json::json!({ + "module.importGroups": [{ "match": "builtin" }] + })); + let mut diags = Vec::new(); + let r = compile(&cfg, &mut diags).unwrap(); + assert_eq!(r.groups.len(), 2); + assert_eq!(r.unknown_index, 1); + } + + #[test] + fn duplicate_category_diagnostic() { + let cfg = build(serde_json::json!({ + "module.importGroups": [ + { "match": "builtin" }, + { "match": "builtin" } + ] + })); + let mut diags = Vec::new(); + let r = compile(&cfg, &mut diags).unwrap(); + assert_eq!(diags.len(), 1); + assert_eq!(r.groups[0].categories, vec![BuiltinCategory::Builtin]); + assert!(r.groups[1].categories.is_empty()); + } + + #[test] + fn type_category_under_interleave_diagnostic() { + let cfg = build(serde_json::json!({ + "module.importGroups": [{ "match": "external" }, { "match": "type" }], + "module.typeImports": "interleave" + })); + let mut diags = Vec::new(); + let _ = compile(&cfg, &mut diags).unwrap(); + assert!(diags.iter().any(|d| d.contains("type") && d.contains("interleave"))); + } +} diff --git a/src/generation/mod.rs b/src/generation/mod.rs index 7696bcb8..c5913883 100644 --- a/src/generation/mod.rs +++ b/src/generation/mod.rs @@ -7,6 +7,8 @@ mod sorting; mod swc; mod tokens; +pub mod imports; + use comments::*; use context::*; use generate_types::*; diff --git a/src/generation/sorting/mod.rs b/src/generation/sorting/mod.rs index 59a5b79a..654f0fac 100644 --- a/src/generation/sorting/mod.rs +++ b/src/generation/sorting/mod.rs @@ -1,4 +1,4 @@ -mod module_specifiers; +pub(crate) mod module_specifiers; use module_specifiers::*; use deno_ast::view::*; @@ -177,7 +177,7 @@ fn cmp_text_case_sensitive(a: &str, b: &str) -> Ordering { a.cmp(b) } -fn cmp_text_case_insensitive(a: &str, b: &str) -> Ordering { +pub(crate) fn cmp_text_case_insensitive(a: &str, b: &str) -> Ordering { let case_insensitive_result = a.to_lowercase().cmp(&b.to_lowercase()); if case_insensitive_result == Ordering::Equal { cmp_text_case_sensitive(a, b) diff --git a/src/utils/builtins.rs b/src/utils/builtins.rs new file mode 100644 index 00000000..2b66078a --- /dev/null +++ b/src/utils/builtins.rs @@ -0,0 +1,81 @@ +//! Built-in module classification. +//! +//! Node core list is a snapshot of `module.builtinModules` from Node 22 LTS. +//! Bun core list is the documented set of `bun:*` namespaces as of Bun 1.1. + +use crate::configuration::BuiltinsRuntime; + +/// Returns true if `src` (the bare specifier string, without surrounding +/// quotes) is a built-in module under the given runtime. +pub fn is_builtin(src: &str, runtime: BuiltinsRuntime) -> bool { + match runtime { + BuiltinsRuntime::Node => has_node_prefix(src) || NODE_CORE.contains(src), + BuiltinsRuntime::Deno => has_node_prefix(src), + BuiltinsRuntime::Bun => has_node_prefix(src) || has_bun_prefix(src) || NODE_CORE.contains(src), + BuiltinsRuntime::None => false, + } +} + +fn has_node_prefix(src: &str) -> bool { + src.starts_with("node:") +} + +fn has_bun_prefix(src: &str) -> bool { + src.starts_with("bun:") +} + +/// Node 22 LTS `module.builtinModules` snapshot (no `node:` prefix). +static NODE_CORE: phf::Set<&'static str> = phf::phf_set! { + "assert", "assert/strict", "async_hooks", "buffer", "child_process", + "cluster", "console", "constants", "crypto", "dgram", "diagnostics_channel", + "dns", "dns/promises", "domain", "events", "fs", "fs/promises", "http", + "http2", "https", "inspector", "inspector/promises", "module", "net", "os", + "path", "path/posix", "path/win32", "perf_hooks", "process", "punycode", + "querystring", "readline", "readline/promises", "repl", "stream", + "stream/consumers", "stream/promises", "stream/web", "string_decoder", + "sys", "test", "timers", "timers/promises", "tls", "trace_events", "tty", + "url", "util", "util/types", "v8", "vm", "wasi", "worker_threads", "zlib", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_runtime_recognizes_node_prefix() { + assert!(is_builtin("node:fs", BuiltinsRuntime::Node)); + assert!(is_builtin("node:path/posix", BuiltinsRuntime::Node)); + } + + #[test] + fn node_runtime_recognizes_bare_core() { + assert!(is_builtin("fs", BuiltinsRuntime::Node)); + assert!(is_builtin("path", BuiltinsRuntime::Node)); + assert!(is_builtin("util/types", BuiltinsRuntime::Node)); + assert!(!is_builtin("react", BuiltinsRuntime::Node)); + } + + #[test] + fn deno_runtime_only_node_prefix() { + assert!(is_builtin("node:fs", BuiltinsRuntime::Deno)); + assert!(!is_builtin("fs", BuiltinsRuntime::Deno)); + assert!(!is_builtin("npm:react", BuiltinsRuntime::Deno)); + assert!(!is_builtin("jsr:@std/path", BuiltinsRuntime::Deno)); + assert!(!is_builtin("https://deno.land/x/foo/mod.ts", BuiltinsRuntime::Deno)); + } + + #[test] + fn bun_runtime_recognizes_bun_prefix() { + assert!(is_builtin("bun:test", BuiltinsRuntime::Bun)); + assert!(is_builtin("bun:sqlite", BuiltinsRuntime::Bun)); + assert!(is_builtin("node:fs", BuiltinsRuntime::Bun)); + assert!(is_builtin("fs", BuiltinsRuntime::Bun)); + } + + #[test] + fn none_runtime_matches_nothing() { + assert!(!is_builtin("fs", BuiltinsRuntime::None)); + assert!(!is_builtin("node:fs", BuiltinsRuntime::None)); + assert!(!is_builtin("bun:test", BuiltinsRuntime::None)); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a471637e..1649e98d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -5,6 +5,8 @@ mod stack; mod string_utils; mod vec_map; +pub mod builtins; + pub use file_text_has_ignore_comment::*; pub use is_prefix_semi_colon_insertion_char::*; pub use stack::*; diff --git a/tests/specs/declarations/import/ImportGroups_Attributes.txt b/tests/specs/declarations/import/ImportGroups_Attributes.txt new file mode 100644 index 00000000..7a24b977 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Attributes.txt @@ -0,0 +1,15 @@ +~~ { + "lineWidth": 80, + "module.importGroups": [{ "match": "external" }, { "match": ["sibling", "index"] }], + "module.sortImportDeclarations": "caseInsensitive" +} ~~ +== import attributes passthrough during reorder == +import { c } from "./c"; +import config from "./config.json" with { type: "json" }; +import { x } from "react"; + +[expect] +import { x } from "react"; + +import { c } from "./c"; +import config from "./config.json" with { type: "json" }; diff --git a/tests/specs/declarations/import/ImportGroups_Barriers.txt b/tests/specs/declarations/import/ImportGroups_Barriers.txt new file mode 100644 index 00000000..850c91fb --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Barriers.txt @@ -0,0 +1,33 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "maintain", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" }, + { "match": ["sibling", "index"] } + ] +} ~~ +== side-effect import is a barrier; imports either side grouped independently == +import { a } from "react"; +import "./side-effect"; +import { fs } from "node:fs"; +import { c } from "./c"; + +[expect] +import { a } from "react"; +import "./side-effect"; + +import { fs } from "node:fs"; + +import { c } from "./c"; + +== leading comment adjacent to import travels with it on reorder == +// keep me with react +import { a } from "react"; +import { fs } from "node:fs"; + +[expect] +import { fs } from "node:fs"; + +// keep me with react +import { a } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_Basic.txt b/tests/specs/declarations/import/ImportGroups_Basic.txt new file mode 100644 index 00000000..8ddf56b6 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Basic.txt @@ -0,0 +1,22 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "maintain", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" }, + { "match": ["sibling", "index"] } + ] +} ~~ +== reorders into builtin / external / sibling+index with blank lines == +import { c } from "./c"; +import { x } from "react"; +import { fs } from "node:fs"; +import { d } from "./index"; + +[expect] +import { fs } from "node:fs"; + +import { x } from "react"; + +import { c } from "./c"; +import { d } from "./index"; diff --git a/tests/specs/declarations/import/ImportGroups_Basic_Sorted.txt b/tests/specs/declarations/import/ImportGroups_Basic_Sorted.txt new file mode 100644 index 00000000..d142c422 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Basic_Sorted.txt @@ -0,0 +1,20 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "external" }, + { "match": ["sibling", "index"] } + ] +} ~~ +== with caseInsensitive sort within each group == +import { B } from "./b"; +import { z } from "zlib2"; +import { a } from "./a"; +import { Alpha } from "alpha"; + +[expect] +import { Alpha } from "alpha"; +import { z } from "zlib2"; + +import { a } from "./a"; +import { B } from "./b"; diff --git a/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Bun.txt b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Bun.txt new file mode 100644 index 00000000..c9594f0c --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Bun.txt @@ -0,0 +1,23 @@ +~~ { + "lineWidth": 80, + "module.builtinsRuntime": "bun", + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" } + ] +} ~~ +== bun runtime: bun: prefix plus node: prefix plus bare core all builtin == +import fs from "fs"; +import { test } from "bun:test"; +import { x } from "node:path"; +import { y } from "npm:react"; +import { z } from "react"; + +[expect] +import { test } from "bun:test"; +import fs from "fs"; +import { x } from "node:path"; + +import { y } from "npm:react"; +import { z } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Deno.txt b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Deno.txt new file mode 100644 index 00000000..32883510 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Deno.txt @@ -0,0 +1,23 @@ +~~ { + "lineWidth": 80, + "module.builtinsRuntime": "deno", + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" } + ] +} ~~ +== deno runtime: only node prefix is builtin == +import fs from "fs"; +import { test } from "bun:test"; +import { x } from "node:path"; +import { y } from "npm:react"; +import { z } from "react"; + +[expect] +import { x } from "node:path"; + +import { test } from "bun:test"; +import fs from "fs"; +import { y } from "npm:react"; +import { z } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Node.txt b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Node.txt new file mode 100644 index 00000000..c3ab6396 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Node.txt @@ -0,0 +1,23 @@ +~~ { + "lineWidth": 80, + "module.builtinsRuntime": "node", + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" } + ] +} ~~ +== node runtime: bare core and node prefix are builtin, others external == +import fs from "fs"; +import { test } from "bun:test"; +import { x } from "node:path"; +import { y } from "npm:react"; +import { z } from "react"; + +[expect] +import fs from "fs"; +import { x } from "node:path"; + +import { test } from "bun:test"; +import { y } from "npm:react"; +import { z } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_None.txt b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_None.txt new file mode 100644 index 00000000..2ea272f6 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_None.txt @@ -0,0 +1,22 @@ +~~ { + "lineWidth": 80, + "module.builtinsRuntime": "none", + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" } + ] +} ~~ +== none runtime: nothing classifies as builtin == +import fs from "fs"; +import { test } from "bun:test"; +import { x } from "node:path"; +import { y } from "npm:react"; +import { z } from "react"; + +[expect] +import { test } from "bun:test"; +import fs from "fs"; +import { x } from "node:path"; +import { y } from "npm:react"; +import { z } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_DeclarationFile.txt b/tests/specs/declarations/import/ImportGroups_DeclarationFile.txt new file mode 100644 index 00000000..0bd38c0d --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_DeclarationFile.txt @@ -0,0 +1,22 @@ +-- file.d.ts -- +~~ { + "lineWidth": 80, + "module.importGroups": [{ "match": "builtin" }, { "match": "external" }], + "module.sortImportDeclarations": "caseInsensitive" +} ~~ +== .d.ts file: imports grouped same as .ts == +import { x } from "react"; +import { fs } from "node:fs"; + +declare module "foo" { + import { internal } from "bar"; +} + +[expect] +import { fs } from "node:fs"; + +import { x } from "react"; + +declare module "foo" { + import { internal } from "bar"; +} diff --git a/tests/specs/declarations/import/ImportGroups_HeaderComment.txt b/tests/specs/declarations/import/ImportGroups_HeaderComment.txt new file mode 100644 index 00000000..253b0376 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_HeaderComment.txt @@ -0,0 +1,20 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" } + ] +} ~~ +== detached license header above first import stays pinned to file start == +/* @license MIT */ + +import { a } from "react"; +import { fs } from "node:fs"; + +[expect] +/* @license MIT */ + +import { fs } from "node:fs"; + +import { a } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_KnobInteractions.txt b/tests/specs/declarations/import/ImportGroups_KnobInteractions.txt new file mode 100644 index 00000000..6b1fffc8 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_KnobInteractions.txt @@ -0,0 +1,11 @@ +~~ { + "lineWidth": 80, + "module.importGroups": [{ "match": "external" }], + "module.sortImportDeclarations": "caseInsensitive", + "importDeclaration.sortNamedImports": "caseInsensitive" +} ~~ +== specifier sort still applies under grouping == +import { b, a, C } from "react"; + +[expect] +import { a, b, C } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_MultiChunk.txt b/tests/specs/declarations/import/ImportGroups_MultiChunk.txt new file mode 100644 index 00000000..2085034f --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_MultiChunk.txt @@ -0,0 +1,24 @@ +~~ { + "lineWidth": 80, + "module.importGroups": [{ "match": "builtin" }, { "match": "external" }], + "module.sortImportDeclarations": "caseInsensitive" +} ~~ +== imports separated by non-import statement form independent chunks == +import { a } from "react"; +import { fs } from "node:fs"; + +const x = 1; + +import { c } from "lodash"; +import { path } from "node:path"; + +[expect] +import { fs } from "node:fs"; + +import { a } from "react"; + +const x = 1; + +import { path } from "node:path"; + +import { c } from "lodash"; diff --git a/tests/specs/declarations/import/ImportGroups_Patterns.txt b/tests/specs/declarations/import/ImportGroups_Patterns.txt new file mode 100644 index 00000000..7d1479c3 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Patterns.txt @@ -0,0 +1,19 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "external" }, + { "match": { "pattern": "@app/**" } }, + { "match": "parent" } + ] +} ~~ +== pattern group positioned after external (external wins via first-match) == +import { c } from "@app/foo"; +import { a } from "react"; +import { b } from "../shared"; + +[expect] +import { c } from "@app/foo"; +import { a } from "react"; + +import { b } from "../shared"; diff --git a/tests/specs/declarations/import/ImportGroups_Patterns_Before.txt b/tests/specs/declarations/import/ImportGroups_Patterns_Before.txt new file mode 100644 index 00000000..7a2d6734 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Patterns_Before.txt @@ -0,0 +1,16 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": { "pattern": "@app/**" } }, + { "match": "external" } + ] +} ~~ +== pattern group positioned before external (pattern wins via first-match) == +import { c } from "@app/foo"; +import { a } from "react"; + +[expect] +import { c } from "@app/foo"; + +import { a } from "react"; diff --git a/tests/specs/declarations/import/ImportGroups_TypeImports_Interleave.txt b/tests/specs/declarations/import/ImportGroups_TypeImports_Interleave.txt new file mode 100644 index 00000000..08e474a6 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_TypeImports_Interleave.txt @@ -0,0 +1,16 @@ +~~ { + "lineWidth": 80, + "module.typeImports": "interleave", + "module.importGroups": [ + { "match": "external" } + ] +} ~~ +== typeImports interleave mixes type and value imports in external group == +import { a } from "alpha"; +import type { B } from "beta"; +import { c } from "gamma"; + +[expect] +import { a } from "alpha"; +import type { B } from "beta"; +import { c } from "gamma"; diff --git a/tests/specs/declarations/import/ImportGroups_TypeImports_Separate.txt b/tests/specs/declarations/import/ImportGroups_TypeImports_Separate.txt new file mode 100644 index 00000000..4540a390 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_TypeImports_Separate.txt @@ -0,0 +1,27 @@ +~~ { + "lineWidth": 80, + "module.sortImportDeclarations": "caseInsensitive", + "module.importGroups": [ + { "match": "external" }, + { "match": "type" } + ] +} ~~ +== typeImports separate (default) pulls import type into its own group == +import { a } from "alpha"; +import type { B } from "beta"; +import { c } from "gamma"; + +[expect] +import { a } from "alpha"; +import { c } from "gamma"; + +import type { B } from "beta"; + +== mixed default plus type specifier stays value == +import Foo, { type Bar } from "alpha"; +import type { Baz } from "beta"; + +[expect] +import Foo, { type Bar } from "alpha"; + +import type { Baz } from "beta"; diff --git a/tests/specs/declarations/import/ImportGroups_Unknown.txt b/tests/specs/declarations/import/ImportGroups_Unknown.txt new file mode 100644 index 00000000..699e9ce5 --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Unknown.txt @@ -0,0 +1,15 @@ +~~ { + "lineWidth": 80, + "module.importGroups": [{ "match": "builtin" }], + "module.sortImportDeclarations": "caseInsensitive" +} ~~ +== implicit catch-all places unmatched imports at end == +import { x } from "react"; +import { fs } from "node:fs"; +import { c } from "./c"; + +[expect] +import { fs } from "node:fs"; + +import { x } from "react"; +import { c } from "./c"; diff --git a/tests/specs/declarations/import/ImportGroups_Unknown_Explicit.txt b/tests/specs/declarations/import/ImportGroups_Unknown_Explicit.txt new file mode 100644 index 00000000..7d99396e --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Unknown_Explicit.txt @@ -0,0 +1,13 @@ +~~ { + "lineWidth": 80, + "module.importGroups": [{ "match": "unknown" }, { "match": "builtin" }], + "module.sortImportDeclarations": "caseInsensitive" +} ~~ +== explicit unknown at start places unmatched imports first == +import { x } from "react"; +import { fs } from "node:fs"; + +[expect] +import { x } from "react"; + +import { fs } from "node:fs";