Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9d4b9dc
docs: add import grouping design (#493)
todor-a May 21, 2026
2b94710
docs: cross-reference Biome organizeImports cases in import grouping …
todor-a May 21, 2026
26a01cd
docs: add opt-in mergeImports option to import grouping design
todor-a May 21, 2026
187a2fc
docs: add module.builtinsRuntime (node/deno/bun/none) to import group…
todor-a May 21, 2026
e807d23
docs: import groups implementation plan
todor-a May 21, 2026
be620ae
feat(config): add TypeImportsMode and BuiltinsRuntime enums
todor-a May 21, 2026
f097b08
feat(config): add ImportGroup, ImportMatcher, BuiltinCategory
todor-a May 21, 2026
6c6c94f
feat(config): add module.importGroups/typeImports/mergeImports/builti…
todor-a May 21, 2026
8ced8fc
feat(config): add builder methods for import grouping config
todor-a May 21, 2026
8f7436e
feat(config): resolve module.importGroups and related keys
todor-a May 21, 2026
3a0badc
build: add globset and phf dependencies
todor-a May 21, 2026
ecd855a
feat: add is_builtin classifier with Node/Deno/Bun runtimes
todor-a May 21, 2026
bb12cd2
chore(imports): scaffold imports submodule tree
todor-a May 21, 2026
c06d926
feat(imports): compile import groups into resolved form
todor-a May 21, 2026
aa06aa3
feat(imports): pure classifier of import sources into resolved groups
todor-a May 21, 2026
59a25ec
feat(imports): stable partition of classified imports
todor-a May 21, 2026
2ceaf0c
refactor(generate): add subgroup_boundaries to StmtGroup
todor-a May 21, 2026
f87fd34
feat(imports): partition imports during get_stmt_groups when enabled
todor-a May 21, 2026
1ddfc2e
feat(imports): force blank line at subgroup boundary (#493)
todor-a May 21, 2026
6e19f31
feat(imports): within-subgroup sort honors module.sortImportDeclarations
todor-a May 21, 2026
f6843f8
test(imports): coverage for typeImports, builtinsRuntime, patterns, b…
todor-a May 21, 2026
81632b1
feat(imports): comments travel with imports on reorder; pin detached …
todor-a May 21, 2026
38eda77
feat(imports): merge eligibility predicate
todor-a May 21, 2026
b538ed6
feat(imports): merge bucket detection (full merge emission TBD)
todor-a May 21, 2026
625f606
feat(config): surface module.importGroups compile diagnostics via res…
todor-a May 21, 2026
5419646
test(imports): coverage for unknown/multi-chunk/attributes/.d.ts/knob…
todor-a May 21, 2026
1749704
docs(imports): README and JSON schema for module.importGroups (#493)
todor-a May 21, 2026
a4f9b0a
chore(imports): silence clippy dead_code warnings, use to_vec
todor-a May 21, 2026
014d938
perf(imports): cleanup after code review (precompute, FxHashSet, drop…
todor-a May 21, 2026
b2e6abf
revert(imports): drop module.mergeImports (was never wired, will revi…
todor-a May 21, 2026
6b07e7c
chore: drop internal planning docs from PR
todor-a May 21, 2026
efd8e51
fix(imports): bypass node-order debug assertion during reordered impo…
todor-a May 21, 2026
a8c550e
chore(imports): remove non-essential comments
todor-a May 21, 2026
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
31 changes: 31 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand All @@ -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"
Expand Down
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 46 additions & 0 deletions deployment/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
23 changes: 23 additions & 0 deletions src/configuration/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImportGroup>) -> &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`).
Expand Down
116 changes: 116 additions & 0 deletions src/configuration/resolve_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<String> = 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,
Expand All @@ -352,6 +366,35 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration)
}
}

fn parse_import_groups(
config: &mut ConfigKeyMap,
diagnostics: &mut Vec<ConfigurationDiagnostic>,
) -> Vec<ImportGroup> {
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::<Vec<ImportGroup>>(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;
Expand Down Expand Up @@ -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<Configuration> {
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::<Vec<_>>());
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::<Vec<_>>()
);
}

#[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::<Vec<_>>());
}
}
Loading
Loading