From 9d4b9dc70256188f4496dab783f349c5dfb527e0 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:30:54 +0300 Subject: [PATCH 01/33] docs: add import grouping design (#493) --- .../specs/2026-05-21-import-groups-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-import-groups-design.md diff --git a/docs/superpowers/specs/2026-05-21-import-groups-design.md b/docs/superpowers/specs/2026-05-21-import-groups-design.md new file mode 100644 index 00000000..4ddeb251 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-import-groups-design.md @@ -0,0 +1,272 @@ +# Import Grouping (issue #493) + +Date: 2026-05-21 +Tracking: https://github.com/dprint/dprint-plugin-typescript/issues/493 + +## Goal + +Add ESLint-`import/order`-style import grouping to dprint-plugin-typescript. +dprint will classify every ES import declaration, reorder them across the +import block to match a user-declared group order, and insert exactly one +blank line between groups. Eliminates the need for `eslint-plugin-import`'s +`order` rule for users on dprint. + +## Non-goals + +- CommonJS `require(...)` ordering. +- Dynamic `import()` expressions. +- Webpack/TS-resolver-based classification (no module resolution performed). +- `eslint-plugin-import` options without a clean dprint analog: + `warnOnUnassignedImports`, `consolidateIslands`, `pathGroupsExcludedImportTypes`, + descending alphabetize, `newlines-between: "never" | "ignore" | "always-and-inside-groups"`. +- TypeScript `import X = require(...)` / `export X = ...`. +- Re-exports `export ... from "..."` (handled by the existing `Exports` group; + not regrouped by this feature). + +## Configuration + +```jsonc +{ + // Ordered list of groups. Empty/absent = feature off; existing behavior preserved. + "module.importGroups": [ + { "match": "builtin" }, + { "match": "external" }, + { "match": "parent" }, + { "match": ["sibling", "index"] } + ], + + // How type-only imports are classified. + // "separate" (default): a distinct implicit category "type"; user places it in the list. + // "interleave" : classified by source path the same as value imports. + "module.typeImports": "separate" +} +``` + +### `match` value forms + +- String: one of `"builtin" | "external" | "parent" | "sibling" | "index" | "type" | "unknown"`. +- Array: union of strings and/or pattern objects, merged into one group (no blank line between). +- Pattern object: `{ "pattern": "" }` — matched against the import source literal (no resolution). +- Arrays may mix: `["external", { "pattern": "@app/**" }]`. + +### Built-in categories + +| Category | Match condition | +|-------------|---------------------------------------------------------------------------------| +| `builtin` | source starts with `node:` OR is in hardcoded Node core list | +| `external` | bare specifier not matched as builtin (e.g. `react`, `@scope/pkg`) | +| `parent` | source starts with `../` | +| `sibling` | source starts with `./` and is not an index path | +| `index` | source is `.`, `./`, `./index`, or `./index.{ts,tsx,js,jsx,mjs,cjs,mts,cts}` | +| `type` | `import type` / `export type` declaration, only when `typeImports: "separate"` | +| `unknown` | implicit catch-all; placed at end if not listed; user may insert explicitly | + +### Resolution & precedence + +- Parsed once in `resolve_config` into `Vec` (`ResolvedGroup` = + `Vec`, `Matcher` = `Category(BuiltinCategory) | Pattern(GlobPattern)`). +- Append implicit `unknown` group if the user did not list one. +- First-match-wins across the resolved list (positional precedence replaces + ESLint `pathGroupsExcludedImportTypes`). +- Diagnostic (warning, first occurrence wins) when the same category appears twice. +- Glob via `globset` crate. + +### Defaults & opt-in + +- Default `module.importGroups` is empty → feature off → byte-identical output + vs. the previous version on the full existing spec suite. +- Default `module.typeImports` is `"separate"` but only takes effect when the + feature is enabled. + +## Approach (locked) + +Approach **A** — subgrouping inside the existing `StmtGroup::Imports` run. + +Rationale: + +- `get_stmt_groups` in `src/generation/generate.rs` already groups consecutive + import declarations into a single `StmtGroup` and excludes side-effect + imports (`!decl.specifiers.is_empty()` filter), so side-effect imports + already act as positional barriers. +- Adding a classify+partition step inside that branch reuses all existing + blank-line, comment, and sort machinery. +- No new global passes; no synthetic-node mechanism needed. + +## Algorithm + +```text +classify(import_decl, config) -> usize // index into resolved groups + src = import_decl.src.value + is_type = import_decl.type_only + || (typeImports == "separate" && every specifier is `type`) + + category = + if is_type && typeImports == "separate": "type" + elif src.starts_with("node:"): "builtin" + elif NODE_CORE_LIST.contains(src): "builtin" + elif src.starts_with("../"): "parent" + elif src is "." || "./" || "./index" + || matches "./index.{ts,tsx,js,jsx,mjs,cjs,mts,cts}": + "index" + elif src.starts_with("./"): "sibling" + else: "external" + + // Walk resolved group list in order; return first index where any matcher + // is `Category(category)` or `Pattern(glob)` matching src. + // If none match: index of `unknown` group (implicit end if absent). +``` + +Within each non-empty partition apply +`get_node_sorter_from_order(module_sort_import_declarations, NamedTypeImportsExportsOrder::None)` +(existing helper). + +## Emission + +Touch points (`src/generation/generate.rs`, ~7300–7470): + +1. Extend `StmtGroup`: + ```rust + struct StmtGroup<'a> { + kind: StmtGroupKind, + nodes: Vec>, + subgroup_boundaries: Option>, // indices into `nodes` where a new subgroup starts + } + ``` + `subgroup_boundaries` is `None` unless `kind == Imports && !config.import_groups.is_empty()`. + +2. Add `partition_import_group(nodes, context) -> (Vec, Vec)`: + - `classify` each node → `(group_idx, node)`. + - Stable-partition by `group_idx` preserving original index for ties. + - Apply the existing node sorter within each partition. + - Concatenate in resolved-group order, recording boundary indices for non-empty partitions. + +3. `get_stmt_groups` calls `partition_import_group` when the group is an + `Imports` group and the feature is enabled; replaces `nodes` and sets + `subgroup_boundaries`. + +4. The `should_use_blank_line` predicate for an `Imports` group: + - Same subgroup → existing behavior (no forced blank line). + - Straddles a boundary → force exactly one blank line. + - Author-written blank lines inside a subgroup are normalized away (the + reorder makes preserving them meaningless). + +## Edge cases + +- **Empty config / feature off**: existing behavior preserved (regression test). +- **All imports one category**: no blank lines inserted (single non-empty subgroup). +- **Side-effect imports in the middle of imports**: already split the + `StmtGroupKind::Imports` run; each side classified independently; positions + preserved. (Matches `import/order` default behavior for side effects.) +- **`// dprint-ignore` on an import**: classification skipped for that node; it + acts as a barrier (preserves position, splits the run). +- **Author-written blank lines inside an import run, feature ON**: ignored; + blank lines are driven by group boundaries. Documented as a behavior change. +- **`import * as X from "..."`**: classified by source like any other import. +- **`import Foo, { type Bar } from "..."`**: `decl.type_only` is false → + classified as value. Only fully `import type` lines hit the `type` category. +- **Glob matches multiple groups**: first listed wins. +- **Category listed twice**: diagnostic, first occurrence used. +- **`unknown` listed explicitly**: that position is used instead of implicit end. +- **`"type"` listed under `typeImports: "interleave"`**: diagnostic; the + category never matches anything in this mode and is ignored. +- **TS `import equals`**: not in the current `Imports` `StmtGroupKind`; + unaffected. Out of scope. +- **`export ... from`**: handled by the `Exports` `StmtGroupKind`; unaffected. + +## Interaction with existing knobs + +| Knob | Interaction | +|---------------------------------------------------|-------------------------------------------------------| +| `module.sortImportDeclarations` | Within-group sort. `Maintain` keeps source order. | +| `module.sortExportDeclarations` | Unchanged (exports unaffected). | +| `importDeclaration.sortNamedImports` | Unchanged. Specifier sort still applies. | +| `importDeclaration.sortTypeOnlyImports` | Unchanged. | +| `importDeclaration.forceSingleLine` / `preferHanging` / `preferSingleLine` | Orthogonal. Apply per-decl after reorder. | + +## Performance + +One classification call per import: string-prefix checks + `NODE_CORE_LIST` +lookup + globset match. Linear in number of imports; negligible vs. full +pretty-print. + +## Testing + +Specs live in `tests/specs/modules/imports/ImportGroups_*.txt` using the +existing dprint spec test format (input → expected, per-spec config). + +### Coverage matrix + +| # | Scenario | +|---|---| +| 1 | Feature off (empty/absent config) — identity on a mixed import block | +| 2 | ESLint mirror `[builtin, external, parent, [sibling, index]]` — reorders, inserts blanks, collapses extras | +| 3 | Single populated category — no blank lines | +| 4 | All imports unmatched — catch-all at end | +| 5 | Explicit `unknown` placement | +| 6 | `node:` prefix and bare core (`fs`) both classified `builtin` | +| 7 | Non-core bare (`react`) → `external` | +| 8 | Pattern glob `@app/**` first-match-wins | +| 9 | Category appearing twice — diagnostic + first wins | +| 10 | `typeImports: "separate"` pulls `import type` into `type` group | +| 11 | `typeImports: "interleave"` mixes `import type` with value by path | +| 12 | Mixed default+type specifier stays value | +| 13 | Side-effect import barrier — each side classified independently | +| 14 | Author-written blank lines normalized to group boundaries when feature on | +| 15 | Leading comments follow their node across reorder | +| 16 | `// dprint-ignore` import excluded and acts as barrier | +| 17 | `module.sortImportDeclarations = Maintain` — cross-group reorder, intra preserves source | +| 18 | `module.sortImportDeclarations = CaseInsensitive` — alphabetical within each group | +| 19 | TS `import equals` unaffected | +| 20 | `export ... from` unaffected | +| 21 | Reverse default order | +| 22 | Pattern group between named groups | +| 23 | Pattern group merged with named via nested array vs separate (distinctGroup analog) | +| 24 | Scoped package `@scope/pkg` → external | +| 25 | Resolver alias `@/foo` via pattern | +| 26 | `react` vs `react-dom` ordering under each `SortOrder` | +| 27 | Multi-line `import { a, b, c } from "..."` straddling a group boundary | +| 28 | Unassigned (side-effect) import between two value imports of different groups | +| 29 | First-match-wins when an import matches two pattern groups | +| 30 | Comments between two imports of different groups | +| 31 | Trailing comment on last import of a group placed correctly with blank line | +| 32 | Mixed `import` and `import type` from the same source path (both modes) | +| 33 | File with a single import — no-op | +| 34 | Interaction with `importDeclaration.forceSingleLine` (width orthogonal) | +| 35 | Interaction with `importDeclaration.sortNamedImports` (specifier sort still applies) | + +### Unit tests (`#[cfg(test)]`) + +- `classify` table tests over `(src, is_type, typeImports_mode) → category`. +- `node_builtins::is_node_builtin(name)` known + unknown cases. +- Config resolution: invalid `match` shapes → diagnostics with location. + +### Snapshot stability + +Full existing spec suite must produce zero diff with the feature disabled. + +## Files touched (estimate) + +- `src/configuration/types.rs` — add `ImportGroup`, `ImportMatcher`, `TypeImportsMode`, + config fields. +- `src/configuration/builder.rs` — builder methods + defaults. +- `src/configuration/resolve_config.rs` — parse + validate `module.importGroups`, + `module.typeImports`; emit diagnostics. +- `src/generation/generate.rs` — extend `StmtGroup`, add + `partition_import_group`, classifier, blank-line predicate update. +- `src/utils/node_builtins.rs` — new file: Node core list + helper. +- `tests/specs/modules/imports/ImportGroups_*.txt` — new spec files. + +## Migration notes for ESLint users + +| ESLint `import/order` option | dprint equivalent | +|--------------------------------------|------------------------------------------------------------| +| `groups` | `module.importGroups` (string entries; nested arrays merge) | +| `pathGroups` | `{ pattern: "..." }` entries placed positionally in `importGroups` | +| `pathGroupsExcludedImportTypes` | Not applicable — list order is precedence | +| `newlines-between: "always"` | Default behavior when feature enabled | +| `newlines-between: "never"/"ignore"` | Set `module.importGroups` to empty (feature off) | +| `alphabetize.order: "asc"` | `module.sortImportDeclarations` = `CaseInsensitive` / `CaseSensitive` | +| `alphabetize.order: "desc"` | Not supported | +| `distinctGroup` (default true) | Default; flatten by nesting array entries to merge | +| `warnOnUnassignedImports` | Not supported (dprint is a formatter, not a linter) | +| `consolidateIslands` | Not supported | From 2b94710a2c9da13ac65a6bc514202dfc5032b20f Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:32:30 +0300 Subject: [PATCH 02/33] docs: cross-reference Biome organizeImports cases in import grouping design --- .../specs/2026-05-21-import-groups-design.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/superpowers/specs/2026-05-21-import-groups-design.md b/docs/superpowers/specs/2026-05-21-import-groups-design.md index 4ddeb251..04c1cd34 100644 --- a/docs/superpowers/specs/2026-05-21-import-groups-design.md +++ b/docs/superpowers/specs/2026-05-21-import-groups-design.md @@ -22,6 +22,13 @@ blank line between groups. Eliminates the need for `eslint-plugin-import`'s - TypeScript `import X = require(...)` / `export X = ...`. - Re-exports `export ... from "..."` (handled by the existing `Exports` group; not regrouped by this feature). +- **Merging duplicate-source imports** (combining `import {a} from "x"` and + `import {b} from "x"` into one). Biome's `organizeImports` does this; + ESLint `import/order` does not. dprint stays formatter-only. +- **Natural sort** of import sources. Existing `SortOrder` (lexicographic, + case-sensitive/-insensitive) only. +- Classifying imports inside nested `declare module "..."` bodies. Only the + top-level statement list of a program is partitioned. ## Configuration @@ -169,6 +176,22 @@ Touch points (`src/generation/generate.rs`, ~7300–7470): - **`unknown` listed explicitly**: that position is used instead of implicit end. - **`"type"` listed under `typeImports: "interleave"`**: diagnostic; the category never matches anything in this mode and is ignored. +- **Unknown category string** (e.g. `"buildin"` typo): config-resolve + diagnostic; entry ignored. +- **File header comments** (license, `// @ts-check`, shebang) above the first + import: detect "detached" leading comments — comments separated from the + first import by at least one blank line — and pin them to the file start. + Only comments adjacent (no blank line) to an import travel with that import + during reorder. +- **Import attributes** (`import x from "y" with { type: "json" }`): + classification uses only `decl.src.value`; attributes are irrelevant and + pass through unchanged. +- **Multiple import chunks separated by non-import statements**: each chunk + grouped independently (existing `get_stmt_groups` chunk boundary). No + cross-chunk reorder. +- **`.d.ts` declaration files**: same code path; no special handling. +- **Imports inside `declare module "..."`**: not classified; nested module + bodies are skipped (top-level program only). - **TS `import equals`**: not in the current `Imports` `StmtGroupKind`; unaffected. Out of scope. - **`export ... from`**: handled by the `Exports` `StmtGroupKind`; unaffected. @@ -233,6 +256,16 @@ existing dprint spec test format (input → expected, per-spec config). | 33 | File with a single import — no-op | | 34 | Interaction with `importDeclaration.forceSingleLine` (width orthogonal) | | 35 | Interaction with `importDeclaration.sortNamedImports` (specifier sort still applies) | +| 36 | License header comment above first import stays pinned to file start after reorder | +| 37 | `// @ts-check` / shebang preservation | +| 38 | Comment directly adjacent to an import (no blank line) travels with it | +| 39 | Import attributes `import x from "y" with { type: "json" }` classification + passthrough | +| 40 | Multiple import chunks separated by a non-import statement — each chunk grouped independently | +| 41 | `.d.ts` declaration file — same behavior | +| 42 | Imports inside `declare module "..."` body — untouched | +| 43 | Unknown category string in config (typo) — diagnostic, entry ignored | +| 44 | `typeImports: "interleave"` with `"type"` listed — diagnostic, ignored | +| 45 | Duplicate-source imports — left as-is (no merge) | ### Unit tests (`#[cfg(test)]`) From 26a01cd991f799c0925feccab51a5df805985bb0 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:36:05 +0300 Subject: [PATCH 03/33] docs: add opt-in mergeImports option to import grouping design --- .../specs/2026-05-21-import-groups-design.md | 69 ++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-05-21-import-groups-design.md b/docs/superpowers/specs/2026-05-21-import-groups-design.md index 04c1cd34..ceb9d813 100644 --- a/docs/superpowers/specs/2026-05-21-import-groups-design.md +++ b/docs/superpowers/specs/2026-05-21-import-groups-design.md @@ -22,9 +22,6 @@ blank line between groups. Eliminates the need for `eslint-plugin-import`'s - TypeScript `import X = require(...)` / `export X = ...`. - Re-exports `export ... from "..."` (handled by the existing `Exports` group; not regrouped by this feature). -- **Merging duplicate-source imports** (combining `import {a} from "x"` and - `import {b} from "x"` into one). Biome's `organizeImports` does this; - ESLint `import/order` does not. dprint stays formatter-only. - **Natural sort** of import sources. Existing `SortOrder` (lexicographic, case-sensitive/-insensitive) only. - Classifying imports inside nested `declare module "..."` bodies. Only the @@ -45,7 +42,12 @@ blank line between groups. Eliminates the need for `eslint-plugin-import`'s // How type-only imports are classified. // "separate" (default): a distinct implicit category "type"; user places it in the list. // "interleave" : classified by source path the same as value imports. - "module.typeImports": "separate" + "module.typeImports": "separate", + + // Merge multiple imports from the same source into one declaration. + // false (default) : leave as written (matches ESLint import/order). + // true : merge compatible duplicates (matches Biome organizeImports). + "module.mergeImports": false } ``` @@ -127,6 +129,44 @@ Within each non-empty partition apply `get_node_sorter_from_order(module_sort_import_declarations, NamedTypeImportsExportsOrder::None)` (existing helper). +### Merge pass (when `module.mergeImports = true`) + +Runs once per subgroup, after the within-group sort. Walks consecutive +declarations; merges runs sharing the same `(source, attributes)` key. + +**Merge eligibility** — two adjacent decls `A` and `B` may merge iff all hold: + +- `A.src.value == B.src.value` (string equality). +- Their import-attributes clauses (`with { ... }`) are structurally equal + (same keys, same string values, same order is not required). +- They do not both declare a default specifier with different local names + (e.g. `import x from "y"` + `import z from "y"` — two defaults, conflict). +- Neither carries `// dprint-ignore`. + +**Merge result:** + +- Specifier set = union of all specifiers from merged decls. +- Specifier order = defaults first, then namespace, then named (sorted by + existing `importDeclaration.sortNamedImports`). +- Type-only mixing: if at least one merged decl is value and at least one is + `import type` (or has per-specifier `type` markers), result is a value + declaration with `type` markers preserved per specifier + (`import { a, type B } from "x"`). If **all** merged decls are + `import type`, result is `import type { ... } from "x"`. +- Leading comments: concatenated in source order above the merged decl, with + blank lines between author-separated blocks preserved. +- Trailing same-line comment: only one allowed; if multiple, keep the first + and emit the rest as preceding line comments of the merged decl, source + order preserved. +- Side-effect import (`import "./x"`) followed by named import from same + source: merged to the named import (named import already triggers eval). + +**Skip cases** (no merge, original decls kept; emit info diagnostic): + +- Two default specifiers with different local names. +- Different attribute clauses. +- Either decl has `// dprint-ignore`. + ## Emission Touch points (`src/generation/generate.rs`, ~7300–7470): @@ -184,8 +224,11 @@ Touch points (`src/generation/generate.rs`, ~7300–7470): Only comments adjacent (no blank line) to an import travel with that import during reorder. - **Import attributes** (`import x from "y" with { type: "json" }`): - classification uses only `decl.src.value`; attributes are irrelevant and - pass through unchanged. + classification reads only `decl.src.value`; attributes are not part of the + category decision. Decls are reordered intact — attribute clauses pass + through verbatim. Attributes do participate in the merge eligibility check + when `mergeImports = true` (two decls with non-equal attribute clauses are + never merged even if their sources match). - **Multiple import chunks separated by non-import statements**: each chunk grouped independently (existing `get_stmt_groups` chunk boundary). No cross-chunk reorder. @@ -265,7 +308,17 @@ existing dprint spec test format (input → expected, per-spec config). | 42 | Imports inside `declare module "..."` body — untouched | | 43 | Unknown category string in config (typo) — diagnostic, entry ignored | | 44 | `typeImports: "interleave"` with `"type"` listed — diagnostic, ignored | -| 45 | Duplicate-source imports — left as-is (no merge) | +| 45 | Duplicate-source imports with `mergeImports: false` (default) — left as-is | +| 46 | `mergeImports: true` — basic merge of `import {a} from "x"; import {b} from "x"` → `import {a, b} from "x"` | +| 47 | `mergeImports: true` — side-effect + named from same source merge to named | +| 48 | `mergeImports: true` — default + namespace from same source merge to `import x, * as y from "z"` | +| 49 | `mergeImports: true` — value + `import type` merge with per-specifier `type` markers | +| 50 | `mergeImports: true` — all-`import type` decls merge to single `import type {...}` | +| 51 | `mergeImports: true` — two conflicting defaults left unmerged + diagnostic | +| 52 | `mergeImports: true` — different `with { ... }` attributes left unmerged | +| 53 | `mergeImports: true` — `// dprint-ignore` on either decl prevents merge | +| 54 | `mergeImports: true` — comments on merged decls preserved above result | +| 55 | `mergeImports: true` interaction with `importDeclaration.sortNamedImports` — merged specifier list sorted | ### Unit tests (`#[cfg(test)]`) @@ -283,7 +336,7 @@ Full existing spec suite must produce zero diff with the feature disabled. config fields. - `src/configuration/builder.rs` — builder methods + defaults. - `src/configuration/resolve_config.rs` — parse + validate `module.importGroups`, - `module.typeImports`; emit diagnostics. + `module.typeImports`, `module.mergeImports`; emit diagnostics. - `src/generation/generate.rs` — extend `StmtGroup`, add `partition_import_group`, classifier, blank-line predicate update. - `src/utils/node_builtins.rs` — new file: Node core list + helper. From 187a2fcd8b80ad982bc1e7dc8398becf55cdc7ff Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:40:11 +0300 Subject: [PATCH 04/33] docs: add module.builtinsRuntime (node/deno/bun/none) to import grouping design --- .../specs/2026-05-21-import-groups-design.md | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-05-21-import-groups-design.md b/docs/superpowers/specs/2026-05-21-import-groups-design.md index ceb9d813..46c5e8e6 100644 --- a/docs/superpowers/specs/2026-05-21-import-groups-design.md +++ b/docs/superpowers/specs/2026-05-21-import-groups-design.md @@ -47,7 +47,14 @@ blank line between groups. Eliminates the need for `eslint-plugin-import`'s // Merge multiple imports from the same source into one declaration. // false (default) : leave as written (matches ESLint import/order). // true : merge compatible duplicates (matches Biome organizeImports). - "module.mergeImports": false + "module.mergeImports": false, + + // What counts as a runtime builtin. + // "node" (default): `node:*` prefix or Node core list (e.g. `fs`, `path`). + // "deno" : `node:*` prefix only. `npm:`, `jsr:`, `https://` → external. + // "bun" : `node:*`, `bun:*`, and Node core list. + // "none" : nothing matches `builtin`; use pattern groups instead. + "module.builtinsRuntime": "node" } ``` @@ -62,14 +69,27 @@ blank line between groups. Eliminates the need for `eslint-plugin-import`'s | Category | Match condition | |-------------|---------------------------------------------------------------------------------| -| `builtin` | source starts with `node:` OR is in hardcoded Node core list | -| `external` | bare specifier not matched as builtin (e.g. `react`, `@scope/pkg`) | +| `builtin` | depends on `module.builtinsRuntime` — see runtime table below | +| `external` | bare specifier not matched as builtin (e.g. `react`, `@scope/pkg`); also `npm:*`, `jsr:*`, `https://*` URL imports under any runtime | | `parent` | source starts with `../` | | `sibling` | source starts with `./` and is not an index path | | `index` | source is `.`, `./`, `./index`, or `./index.{ts,tsx,js,jsx,mjs,cjs,mts,cts}` | | `type` | `import type` / `export type` declaration, only when `typeImports: "separate"` | | `unknown` | implicit catch-all; placed at end if not listed; user may insert explicitly | +### Builtin runtime table + +| `module.builtinsRuntime` | Matches as `builtin` | +|--------------------------|--------------------------------------------------------------------------| +| `"node"` (default) | `node:*` prefix OR source in shipped Node core list | +| `"deno"` | `node:*` prefix only | +| `"bun"` | `node:*` prefix, `bun:*` prefix, OR source in shipped Node core list | +| `"none"` | nothing | + +Shipped lists are hardcoded snapshots — Node core from the Node 22 LTS +`module.builtinModules`, Bun core from Bun's documented `bun:*` modules. +Snapshot version recorded in the source file header for future bumps. + ### Resolution & precedence - Parsed once in `resolve_config` into `Vec` (`ResolvedGroup` = @@ -111,8 +131,7 @@ classify(import_decl, config) -> usize // index into resolved groups category = if is_type && typeImports == "separate": "type" - elif src.starts_with("node:"): "builtin" - elif NODE_CORE_LIST.contains(src): "builtin" + elif is_builtin(src, config.builtinsRuntime): "builtin" elif src.starts_with("../"): "parent" elif src is "." || "./" || "./index" || matches "./index.{ts,tsx,js,jsx,mjs,cjs,mts,cts}": @@ -120,6 +139,13 @@ classify(import_decl, config) -> usize // index into resolved groups elif src.starts_with("./"): "sibling" else: "external" +is_builtin(src, runtime): + match runtime: + "node": src.starts_with("node:") || NODE_CORE_LIST.contains(src) + "deno": src.starts_with("node:") + "bun" : src.starts_with("node:") || src.starts_with("bun:") || NODE_CORE_LIST.contains(src) + "none": false + // Walk resolved group list in order; return first index where any matcher // is `Category(category)` or `Pattern(glob)` matching src. // If none match: index of `unknown` group (implicit end if absent). @@ -319,6 +345,12 @@ existing dprint spec test format (input → expected, per-spec config). | 53 | `mergeImports: true` — `// dprint-ignore` on either decl prevents merge | | 54 | `mergeImports: true` — comments on merged decls preserved above result | | 55 | `mergeImports: true` interaction with `importDeclaration.sortNamedImports` — merged specifier list sorted | +| 56 | `builtinsRuntime: "node"` (default) — bare `fs` → builtin | +| 57 | `builtinsRuntime: "deno"` — bare `fs` → external (no core list); `node:fs` → builtin | +| 58 | `builtinsRuntime: "deno"` — `npm:react`, `jsr:@std/path`, `https://deno.land/x/foo/mod.ts` → external | +| 59 | `builtinsRuntime: "bun"` — `bun:test` → builtin; `bun:sqlite` → builtin; bare `fs` → builtin | +| 60 | `builtinsRuntime: "none"` — nothing classified as builtin; user-defined pattern groups handle everything | +| 61 | Invalid `builtinsRuntime` string — diagnostic, default to `"node"` | ### Unit tests (`#[cfg(test)]`) @@ -336,10 +368,11 @@ Full existing spec suite must produce zero diff with the feature disabled. config fields. - `src/configuration/builder.rs` — builder methods + defaults. - `src/configuration/resolve_config.rs` — parse + validate `module.importGroups`, - `module.typeImports`, `module.mergeImports`; emit diagnostics. + `module.typeImports`, `module.mergeImports`, `module.builtinsRuntime`; emit diagnostics. - `src/generation/generate.rs` — extend `StmtGroup`, add `partition_import_group`, classifier, blank-line predicate update. -- `src/utils/node_builtins.rs` — new file: Node core list + helper. +- `src/utils/builtins.rs` — new file: Node core list, Bun core list, + `is_builtin(src, runtime)` helper. - `tests/specs/modules/imports/ImportGroups_*.txt` — new spec files. ## Migration notes for ESLint users From e807d2320b304f5ed257b2762ca98907e5bb5b4d Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:49:19 +0300 Subject: [PATCH 05/33] docs: import groups implementation plan --- .../plans/2026-05-21-import-groups.md | 2543 +++++++++++++++++ 1 file changed, 2543 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-import-groups.md diff --git a/docs/superpowers/plans/2026-05-21-import-groups.md b/docs/superpowers/plans/2026-05-21-import-groups.md new file mode 100644 index 00000000..89d89530 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-import-groups.md @@ -0,0 +1,2543 @@ +# Import Grouping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add ESLint-`import/order`-style grouping of TypeScript/JavaScript +import declarations to dprint-plugin-typescript, with optional Biome-style +merging and per-runtime builtin classification. + +**Architecture:** Extend the existing `get_stmt_groups` / +`StmtGroupKind::Imports` path in `src/generation/generate.rs`. A new pure +classifier+partitioner runs over each consecutive run of `ImportDecl`s, +producing subgroups. The blank-line predicate is taught to force a blank +between subgroups. Side-effect imports already split runs naturally, so they +remain barriers. Everything is opt-in via a new `module.importGroups` config +key — default empty = byte-identical output to today's release. + +**Tech Stack:** Rust 2024, `deno_ast`/SWC views, `dprint-core` PrintItems, +`globset` (new dep), `phf` (new dep, for compile-time core-module hash set), +the existing dprint spec test harness in `tests/spec_test.rs`. + +**Spec:** `docs/superpowers/specs/2026-05-21-import-groups-design.md`. + +--- + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `src/utils/builtins.rs` | new | Node 22 / Bun builtin module lists, `is_builtin(src, runtime)`. | +| `src/utils/mod.rs` | modify | `pub mod builtins;` | +| `src/configuration/types.rs` | modify | New enums `TypeImportsMode`, `BuiltinsRuntime`; new struct `ImportGroup` + `ImportMatcher`; new fields on `Configuration`. | +| `src/configuration/builder.rs` | modify | Builder methods + defaults for the four new keys. | +| `src/configuration/resolve_config.rs` | modify | Parse, validate, compile patterns into resolved import groups; diagnostics. | +| `src/generation/imports/mod.rs` | new | Sub-module root: `pub mod classify; pub mod partition; pub mod merge;` | +| `src/generation/imports/classify.rs` | new | Pure classifier: `(src, is_type, &ResolvedGroups, &Config) → usize`. | +| `src/generation/imports/partition.rs` | new | Stable partition + within-group sort. Returns `(Vec, Vec boundaries)`. | +| `src/generation/imports/merge.rs` | new | Optional merge pass over a classified subgroup. | +| `src/generation/mod.rs` | modify | `pub mod imports;` | +| `src/generation/generate.rs` | modify | Extend `StmtGroup`, call `partition_import_group`, force blank line at subgroup boundary. Header-comment pinning. | +| `tests/specs/declarations/import/ImportGroups_*.txt` | new | Spec-test files. | +| `deployment/schema.json` | modify | Add JSON schema entries for the four new keys (if file exists in repo). | +| `Cargo.toml` | modify | Add `globset` (and `phf` if not in deno_ast transitive). | + +--- + +## Conventions Used Throughout + +- All new code lives in modules small enough to hold in head; each new file + has one clear responsibility. +- TDD: every behavior change starts with a failing test (Rust unit test or + dprint spec test) before the implementation step. +- Commit after every passing test step. Conventional Commits prefixes: + `feat:`, `test:`, `refactor:`, `docs:`. Issue tag `(#493)` in subject of + the last user-visible commit per phase. +- Branch already exists: `feat-import-groups`. Run `git switch + feat-import-groups` before starting. +- `cargo test --test specs` runs the dprint spec suite; `cargo test --lib` + runs Rust unit tests. + +--- + +## Phase 0: Branch & Baseline + +### Task 0.1: Switch to branch, run baseline tests green + +**Files:** none. + +- [ ] **Step 1: Switch branch** + +```bash +cd /Users/todor.andonov/projects/oss/dprint-plugin-typescript +git switch feat-import-groups +``` + +- [ ] **Step 2: Run full test suite to confirm green baseline** + +```bash +cargo test --release +``` + +Expected: all tests pass. If not, stop and investigate — do not start the +feature on a red baseline. + +--- + +## Phase 1: Configuration types (no behavior change) + +### Task 1.1: Add `TypeImportsMode` and `BuiltinsRuntime` enums + +**Files:** +- Modify: `src/configuration/types.rs` + +- [ ] **Step 1: Add a failing unit test for round-trip string conversion** + +Append to `src/configuration/types.rs`: + +```rust +#[cfg(test)] +mod import_group_enum_tests { + use super::*; + + #[test] + fn type_imports_mode_round_trip() { + assert_eq!(TypeImportsMode::from_str("separate"), Ok(TypeImportsMode::Separate)); + assert_eq!(TypeImportsMode::from_str("interleave"), Ok(TypeImportsMode::Interleave)); + assert_eq!(TypeImportsMode::Separate.to_string(), "separate"); + } + + #[test] + fn builtins_runtime_round_trip() { + assert_eq!(BuiltinsRuntime::from_str("node"), Ok(BuiltinsRuntime::Node)); + assert_eq!(BuiltinsRuntime::from_str("deno"), Ok(BuiltinsRuntime::Deno)); + assert_eq!(BuiltinsRuntime::from_str("bun"), Ok(BuiltinsRuntime::Bun)); + assert_eq!(BuiltinsRuntime::from_str("none"), Ok(BuiltinsRuntime::None)); + assert_eq!(BuiltinsRuntime::Node.to_string(), "node"); + } +} +``` + +- [ ] **Step 2: Run the test, confirm it fails** + +```bash +cargo test --lib import_group_enum_tests +``` + +Expected: compile error — `TypeImportsMode` / `BuiltinsRuntime` undefined. + +- [ ] **Step 3: Add the enums and `generate_str_to_from!` invocations** + +Insert before the `Configuration` struct (around line 309) in +`src/configuration/types.rs`: + +```rust +/// 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"] +]; +``` + +- [ ] **Step 4: Run the test, confirm it passes** + +```bash +cargo test --lib import_group_enum_tests +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/configuration/types.rs +git commit -m "feat(config): add TypeImportsMode and BuiltinsRuntime enums" +``` + +### Task 1.2: Add `ImportGroup` / `ImportMatcher` value types + +**Files:** +- Modify: `src/configuration/types.rs` + +- [ ] **Step 1: Add a failing unit test** + +Append to the same `#[cfg(test)] mod import_group_enum_tests` block: + +```rust +#[test] +fn import_matcher_variants() { + let _c = ImportMatcher::Category(BuiltinCategory::External); + let _p = ImportMatcher::Pattern("foo/*".to_string()); +} + +#[test] +fn builtin_category_round_trip() { + assert_eq!(BuiltinCategory::from_str("builtin"), Ok(BuiltinCategory::Builtin)); + assert_eq!(BuiltinCategory::from_str("external"), Ok(BuiltinCategory::External)); + assert_eq!(BuiltinCategory::from_str("parent"), Ok(BuiltinCategory::Parent)); + assert_eq!(BuiltinCategory::from_str("sibling"), Ok(BuiltinCategory::Sibling)); + assert_eq!(BuiltinCategory::from_str("index"), Ok(BuiltinCategory::Index)); + assert_eq!(BuiltinCategory::from_str("type"), Ok(BuiltinCategory::Type)); + assert_eq!(BuiltinCategory::from_str("unknown"), Ok(BuiltinCategory::Unknown)); +} +``` + +- [ ] **Step 2: Confirm test fails** + +```bash +cargo test --lib import_group_enum_tests +``` + +Expected: compile error — types undefined. + +- [ ] **Step 3: Add `BuiltinCategory`, `ImportMatcher`, `ImportGroup`** + +In `src/configuration/types.rs`, after the `BuiltinsRuntime` block: + +```rust +/// Built-in category strings allowed in `module.importGroups[].match`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, 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, Debug)] +pub enum ImportMatcher { + Category(BuiltinCategory), + /// Raw glob pattern string. Compiled lazily by resolve_config into a globset. + Pattern(String), +} + +/// One resolved import group, in user-listed order. +#[derive(Clone, Debug)] +pub struct ImportGroup { + pub matchers: Vec, +} +``` + +- [ ] **Step 4: Test passes** + +```bash +cargo test --lib import_group_enum_tests +``` + +Expected: all 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/configuration/types.rs +git commit -m "feat(config): add ImportGroup, ImportMatcher, BuiltinCategory" +``` + +### Task 1.3: Add config fields to `Configuration` + +**Files:** +- Modify: `src/configuration/types.rs` + +- [ ] **Step 1: Add fields** + +Find the `/* sorting */` block (around line 344) in `Configuration`. Insert +after `export_declaration_sort_type_only_exports: NamedTypeImportsExportsOrder,`: + +```rust + #[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.mergeImports", default)] + pub module_merge_imports: bool, + #[serde(rename = "module.builtinsRuntime", default = "default_builtins_runtime")] + pub module_builtins_runtime: BuiltinsRuntime, +``` + +At the end of the file (before any `#[cfg(test)]` blocks), add: + +```rust +fn default_type_imports_mode() -> TypeImportsMode { + TypeImportsMode::Separate +} + +fn default_builtins_runtime() -> BuiltinsRuntime { + BuiltinsRuntime::Node +} +``` + +- [ ] **Step 2: Manually add `Serialize` / `Deserialize` derive on `ImportGroup` and `ImportMatcher`** + +Replace: + +```rust +#[derive(Clone, Debug)] +pub enum ImportMatcher { ... } + +#[derive(Clone, Debug)] +pub struct ImportGroup { ... } +``` + +with: + +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImportMatcher { + Category(BuiltinCategory), + Pattern { pattern: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ImportGroup { + /// Either a single matcher or a list (list = merged into one group). + #[serde(rename = "match")] + pub matchers: ImportGroupMatch, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImportGroupMatch { + Single(ImportMatcher), + Multiple(Vec), +} +``` + +Update the earlier unit test that used `ImportMatcher::Pattern("foo/*".to_string())` to: + +```rust +let _p = ImportMatcher::Pattern { pattern: "foo/*".to_string() }; +``` + +- [ ] **Step 3: Run cargo check** + +```bash +cargo check --lib +``` + +Expected: compiles. Resolve any errors before continuing. + +- [ ] **Step 4: Run lib tests** + +```bash +cargo test --lib +``` + +Expected: all existing tests still pass + the new ones from 1.1/1.2. + +- [ ] **Step 5: Commit** + +```bash +git add src/configuration/types.rs +git commit -m "feat(config): add module.importGroups/typeImports/mergeImports/builtinsRuntime fields" +``` + +### Task 1.4: Builder methods + +**Files:** +- Modify: `src/configuration/builder.rs` + +- [ ] **Step 1: Find existing builder defaults block** + +Look at lines 65–75 of `src/configuration/builder.rs` — there's a default +chain calling `.module_sort_import_declarations(SortOrder::Maintain)`. Add +the four new defaults right after it. + +- [ ] **Step 2: Append four builder methods** + +In `src/configuration/builder.rs`, after +`pub fn export_declaration_sort_type_only_exports(...)` (around line 577), +insert: + +```rust + /// Ordered groups for `module.importGroups`. Empty = feature disabled. + /// + /// Default: `[]` + pub fn module_import_groups(&mut self, value: Vec) -> &mut Self { + self.insert("module.importGroups", serde_json::to_value(value).unwrap().into()) + } + + /// 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()) + } + + /// Merge multiple imports from the same source into one declaration. + /// + /// Default: `false` + pub fn module_merge_imports(&mut self, value: bool) -> &mut Self { + self.insert("module.mergeImports", value.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()) + } +``` + +Also add `BuiltinsRuntime, TypeImportsMode` to the `use` imports at the top +of the file. + +- [ ] **Step 3: cargo check** + +```bash +cargo check --lib +``` + +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add src/configuration/builder.rs +git commit -m "feat(config): add builder methods for import grouping config" +``` + +### Task 1.5: Resolve config (parse + diagnostics) + +**Files:** +- Modify: `src/configuration/resolve_config.rs` + +- [ ] **Step 1: Add `get_value` calls for the three scalar keys** + +Find the `/* sorting */` block in `resolve_config` (around line 118). After +the `export_declaration_sort_type_only_exports` block (which spans roughly +129–134), insert: + +```rust + module_import_groups: parse_import_groups(&mut config, &mut diagnostics), + module_type_imports: get_value(&mut config, "module.typeImports", TypeImportsMode::Separate, &mut diagnostics), + module_merge_imports: get_value(&mut config, "module.mergeImports", false, &mut diagnostics), + module_builtins_runtime: get_value(&mut config, "module.builtinsRuntime", BuiltinsRuntime::Node, &mut diagnostics), +``` + +- [ ] **Step 2: Add `parse_import_groups` helper at the bottom of the file** + +At the end of `src/configuration/resolve_config.rs`: + +```rust +fn parse_import_groups( + config: &mut ConfigKeyMap, + diagnostics: &mut Vec, +) -> Vec { + let Some(raw) = config.shift_remove("module.importGroups") else { + return Vec::new(); + }; + match serde_json::from_value::>(raw.clone().into()) { + Ok(groups) => groups, + Err(err) => { + diagnostics.push(ConfigurationDiagnostic { + property_name: "module.importGroups".to_string(), + message: format!("Invalid import groups configuration: {err}"), + }); + Vec::new() + } + } +} +``` + +Add `use crate::configuration::{ImportGroup, TypeImportsMode, BuiltinsRuntime};` to the imports at top if not already present. + +- [ ] **Step 3: cargo check** + +```bash +cargo check --lib +``` + +Expected: compiles. + +- [ ] **Step 4: Add a config-resolution unit test** + +Append to `src/configuration/resolve_config.rs`: + +```rust +#[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_eq!(r.config.module_type_imports, TypeImportsMode::Separate); + assert!(!r.config.module_merge_imports); + assert_eq!(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()); + 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"); + } +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cargo test --lib import_groups_resolution_tests +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/configuration/resolve_config.rs +git commit -m "feat(config): resolve module.importGroups and related keys" +``` + +### Task 1.6: Regression check — feature off must be byte-identical + +**Files:** none. + +- [ ] **Step 1: Run full spec suite** + +```bash +cargo test --test specs +``` + +Expected: every existing spec still passes. (Feature is opt-in via empty +`module.importGroups`; default is empty; nothing in generation has changed.) + +- [ ] **Step 2: Tag the byte-identical baseline** + +```bash +git tag baseline-pre-import-groups +``` + +Used later to compare against feature-off output. + +--- + +## Phase 2: Builtins utility + +### Task 2.1: Add `phf` and `globset` deps + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Add deps** + +Edit `Cargo.toml` `[dependencies]` to add: + +```toml +globset = "0.4" +phf = { version = "0.11", features = ["macros"] } +``` + +- [ ] **Step 2: cargo check** + +```bash +cargo check --lib +``` + +Expected: deps resolve and compile. + +- [ ] **Step 3: Commit** + +```bash +git add Cargo.toml Cargo.lock +git commit -m "build: add globset and phf dependencies" +``` + +### Task 2.2: Implement `builtins.rs` with Node core list + +**Files:** +- Create: `src/utils/builtins.rs` +- Modify: `src/utils/mod.rs` + +- [ ] **Step 1: Add to module root** + +In `src/utils/mod.rs`, append: + +```rust +pub mod builtins; +``` + +- [ ] **Step 2: Create `src/utils/builtins.rs` with failing tests** + +```rust +//! 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)); + } +} +``` + +- [ ] **Step 3: Run unit tests** + +```bash +cargo test --lib utils::builtins +``` + +Expected: 5 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/utils/mod.rs src/utils/builtins.rs +git commit -m "feat: add is_builtin classifier with Node/Deno/Bun runtimes" +``` + +--- + +## Phase 3: Classifier (pure) + +### Task 3.1: Skeleton module + +**Files:** +- Create: `src/generation/imports/mod.rs` +- Modify: `src/generation/mod.rs` + +- [ ] **Step 1: Create sub-module** + +`src/generation/imports/mod.rs`: + +```rust +pub mod classify; +pub mod partition; +pub mod merge; +pub mod resolved; +``` + +- [ ] **Step 2: Register in parent** + +In `src/generation/mod.rs`, append: + +```rust +pub mod imports; +``` + +- [ ] **Step 3: cargo check fails because submodules don't exist** + +Expected. Continue. + +### Task 3.2: Resolved-config compile step (compile patterns, append `unknown`) + +**Files:** +- Create: `src/generation/imports/resolved.rs` + +- [ ] **Step 1: Write failing test** + +Create `src/generation/imports/resolved.rs`: + +```rust +//! Compiled form of `module.importGroups` ready for fast classification. + +use globset::{Glob, GlobSet, GlobSetBuilder}; + +use crate::configuration::{BuiltinCategory, Configuration, ImportGroup, ImportGroupMatch, ImportMatcher}; + +/// One resolved group: a set of categories + a glob set, in user-listed order. +/// `unknown_index` records which resolved group catches unmatched imports. +#[derive(Debug)] +pub struct ResolvedGroup { + pub categories: Vec, + pub globs: GlobSet, + pub has_globs: bool, +} + +#[derive(Debug)] +pub struct ResolvedGroups { + pub groups: Vec, + pub unknown_index: usize, +} + +/// Compile config's `module.importGroups` into resolved form. +/// `diagnostics` is appended to on bad globs or duplicate categories. +pub fn compile(config: &Configuration, diagnostics: &mut Vec) -> Option { + if config.module_import_groups.is_empty() { + return None; + } + + 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 !seen_categories.insert(*c) { + diagnostics.push(format!("Category `{c:?}` listed more than once in module.importGroups; 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!("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::ConfigurationBuilder; + + fn build(json: serde_json::Value) -> Configuration { + let mut b = ConfigurationBuilder::new(); + let map: dprint_core::configuration::ConfigKeyMap = serde_json::from_value(json).unwrap(); + b.global_config(Default::default()); + for (k, v) in map.into_iter() { + b.insert(&k, v.into()); + } + b.build() + } + + #[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); + // First group still got the builtin; second has empty categories. + assert_eq!(r.groups[0].categories, vec![BuiltinCategory::Builtin]); + assert!(r.groups[1].categories.is_empty()); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test --lib generation::imports::resolved +``` + +Expected: 3 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/generation/imports/mod.rs src/generation/imports/resolved.rs src/generation/mod.rs +git commit -m "feat(imports): compile import groups into resolved form" +``` + +### Task 3.3: Classifier function + +**Files:** +- Create: `src/generation/imports/classify.rs` + +- [ ] **Step 1: Write file with tests + implementation** + +```rust +//! 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 && 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 { + if src == "." || src == "./" || src == "./index" { + return true; + } + for ext in [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] { + let candidate = format!("./index{ext}"); + if src == candidate { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::configuration::{ConfigurationBuilder, BuiltinsRuntime, TypeImportsMode}; + use crate::generation::imports::resolved::compile; + + fn classify_with(json: serde_json::Value, src: &str, is_type: bool) -> usize { + let cfg = { + let mut b = ConfigurationBuilder::new(); + b.global_config(Default::default()); + let map: dprint_core::configuration::ConfigKeyMap = serde_json::from_value(json).unwrap(); + for (k, v) in map.into_iter() { + b.insert(&k, v.into()); + } + b.build() + }; + 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" }] + }); + // `react` is external, not builtin → falls to implicit unknown (index 1). + 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" } + ] + }); + // Value import of external → 0; type import → 1. + 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/**" } } + ] + }); + // `@app/foo` matches `external` first → 0. + 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, "@app/foo", false), 0); + assert_eq!(classify_with(cfg, "react", false), 1); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test --lib generation::imports::classify +``` + +Expected: 9 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/generation/imports/classify.rs +git commit -m "feat(imports): pure classifier of import sources into resolved groups" +``` + +--- + +## Phase 4: Partitioner + +### Task 4.1: `partition_import_group` + +**Files:** +- Create: `src/generation/imports/partition.rs` + +- [ ] **Step 1: Write skeleton + tests** + +Because partitioning operates on SWC `Node`s with lifetimes, the tests will +use a thin abstraction. Write: + +```rust +//! 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, +{ + // Bucket by group_index, preserving relative order (stable). + let mut buckets: Vec> = (0..num_groups).map(|_| Vec::new()).collect(); + for &(g, orig) in classified { + buckets[g].push(orig); + } + + // Sort within each bucket using the provided comparator. + for b in buckets.iter_mut() { + b.sort_by(|&a, &b| cmp_within_group(a, b)); + } + + // Flatten + record boundaries. + 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() { + // Originals classified as group 0, 2, 1, 0, 2. + let input = vec![(0, 0), (2, 1), (1, 2), (0, 3), (2, 4)]; + let (ordered, boundaries) = partition_indices(&input, 3, equal); + // Group 0 first (originals 0, 3); then group 1 (2); then group 2 (1, 4). + 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]); + // Group 1 is empty so only two boundary indices (one per non-empty bucket). + assert_eq!(boundaries, vec![0, 1]); + } + + #[test] + fn within_group_sort_applies() { + // Single group of 3 items; sort descending by original index. + 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]); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test --lib generation::imports::partition +``` + +Expected: 3 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/generation/imports/partition.rs +git commit -m "feat(imports): stable partition of classified imports" +``` + +--- + +## Phase 5: Integration into `gen_statements` + +### Task 5.1: Stub `merge` module + +**Files:** +- Create: `src/generation/imports/merge.rs` + +- [ ] **Step 1: Empty placeholder** + +```rust +//! Merge pass for `module.mergeImports: true`. Implemented in Phase 8. +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/generation/imports/merge.rs +git commit -m "chore(imports): stub merge module" +``` + +### Task 5.2: Extend `StmtGroup` with subgroup boundaries + +**Files:** +- Modify: `src/generation/generate.rs` + +- [ ] **Step 1: Update struct** + +Locate `struct StmtGroup<'a>` (around line 7416). Replace with: + +```rust +struct StmtGroup<'a> { + kind: StmtGroupKind, + nodes: Vec>, + /// Indices into `nodes` marking start of each subgroup. Only Some for + /// import groups when `module.importGroups` is non-empty. + subgroup_boundaries: Option>, +} +``` + +Update the three construction sites in `get_stmt_groups` to include +`subgroup_boundaries: None,`. + +- [ ] **Step 2: cargo check** + +```bash +cargo check --lib +``` + +Expected: compiles. + +- [ ] **Step 3: Commit** + +```bash +git add src/generation/generate.rs +git commit -m "refactor(generate): add subgroup_boundaries to StmtGroup" +``` + +### Task 5.3: Partition imports during `get_stmt_groups` + +**Files:** +- Modify: `src/generation/generate.rs` + +- [ ] **Step 1: Add classification + partition call** + +At the end of `get_stmt_groups`, just before `groups` is returned, insert: + +```rust + // Apply import-group partitioning when enabled. + if let Some(resolved) = context.resolved_import_groups.as_ref() { + for g in groups.iter_mut() { + if g.kind != StmtGroupKind::Imports { + continue; + } + let classified: Vec<(usize, usize)> = g + .nodes + .iter() + .enumerate() + .map(|(i, node)| { + let (src, is_type) = if let Node::ImportDecl(d) = node { + (d.src.value().as_str().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(); + let (ordered, boundaries) = crate::generation::imports::partition::partition_indices( + &classified, + resolved.groups.len(), + |_a, _b| std::cmp::Ordering::Equal, // intra-group sort handled by existing sorter later + ); + // Reorder nodes in place. + let mut new_nodes: Vec = Vec::with_capacity(g.nodes.len()); + for orig in &ordered { + new_nodes.push(g.nodes[*orig]); + } + g.nodes = new_nodes; + g.subgroup_boundaries = Some(boundaries); + } + } +``` + +- [ ] **Step 2: Add `resolved_import_groups` to `Context`** + +In `src/generation/context.rs`, add to the `Context` struct (find the +`struct Context<'a>` definition): + +```rust + pub resolved_import_groups: Option, +``` + +Initialize it from `Context::new` — find the constructor, add a parameter or +compute from config. The simplest path: compute inline at the call site in +`gen_program` (look for `Context::new(...)`) and pass it in. + +**Where to wire it (concrete instructions):** + +1. Find `Context::new` definition in `src/generation/context.rs`. It currently + takes a number of args (`file_path`, `program`, `config`, etc.). Do NOT + add another argument — instead, compute `resolved_import_groups` from + `config` inside `Context::new` itself. Add this line just before the + struct literal `Context { ... }` is returned: + + ```rust + let mut _import_group_diags: Vec = Vec::new(); + let resolved_import_groups = crate::generation::imports::resolved::compile(config, &mut _import_group_diags); + // diagnostics dropped here for now; surfaced via resolve_config in Task 9.3 + ``` + +2. Add `resolved_import_groups,` to the struct literal. + +This avoids touching every `Context::new` caller in this task. + +- [ ] **Step 3: cargo build** + +```bash +cargo build --lib +``` + +Expected: compiles. Tests not run yet — behavior change comes in Task 5.4. + +- [ ] **Step 4: Commit** + +```bash +git add src/generation/generate.rs src/generation/context.rs +git commit -m "feat(imports): partition imports during get_stmt_groups when enabled" +``` + +### Task 5.4: Force blank line at subgroup boundary + +**Files:** +- Modify: `src/generation/generate.rs` + +- [ ] **Step 1: Update blank-line decision inside `gen_members`-for-statements loop** + +Locate the block at the line numbers around 7310–7314 (currently shown +above), where `has_separating_blank_line` decides whether to push a second +NewLine. Replace that conditional with one that also consults the subgroup +boundary: + +```rust + if let Some(last_node) = &last_node { + separator_items.push_signal(Signal::NewLine); + let crosses_subgroup_boundary = stmt_group + .subgroup_boundaries + .as_ref() + .map(|bs| bs.contains(&i)) + .unwrap_or(false); + if crosses_subgroup_boundary + || node_helpers::has_separating_blank_line(last_node, &node, context.program) + { + separator_items.push_signal(Signal::NewLine); + } + generated_line_separators.insert(i, separator_items); + } +``` + +Important: `i` is the *post-reorder* index. `subgroup_boundaries` stores +post-reorder indices, so this is correct. The first boundary (index 0) is +naturally skipped because `last_node` is `None` then. + +- [ ] **Step 2: cargo build** + +```bash +cargo build --lib +``` + +Expected: compiles. + +- [ ] **Step 3: First end-to-end spec test** + +Create `tests/specs/declarations/import/ImportGroups_Basic.txt`: + +``` +~~ lineWidth: 80, module.importGroups: [{"match":"builtin"},{"match":"external"},{"match":["sibling","index"]}], module.sortImportDeclarations: maintain ~~ +== 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"; +``` + +- [ ] **Step 4: Run new spec** + +```bash +cargo test --test specs declarations::import::ImportGroups_Basic +``` + +Expected: passes. If output diff appears, inspect — likely the blank line is +in the wrong place or sorter is reordering further. + +- [ ] **Step 5: Run full spec suite** + +```bash +cargo test --test specs +``` + +Expected: all existing specs still pass. Feature-off byte-identical. + +- [ ] **Step 6: Commit** + +```bash +git add src/generation/generate.rs tests/specs/declarations/import/ImportGroups_Basic.txt +git commit -m "feat(imports): force blank line at subgroup boundary (#493)" +``` + +--- + +## Phase 6: Within-group sort + spec coverage + +### Task 6.1: Wire intra-subgroup sorter + +**Files:** +- Modify: `src/generation/generate.rs` + +- [ ] **Step 1: Replace the `Equal` placeholder in Task 5.3's partition call** + +Find the `|_a, _b| std::cmp::Ordering::Equal` closure. Replace with a real +comparator that: + +- Reads `context.config.module_sort_import_declarations`. +- If `Maintain`: returns `Equal` (preserves source order in each bucket). +- Else: uses `cmp_module_specifiers` (existing helper in + `src/generation/sorting/module_specifiers.rs`) over the two nodes' source + strings, with `str::cmp` for `CaseSensitive` and case-insensitive cmp for + `CaseInsensitive`. + +```rust + use crate::configuration::SortOrder; + use crate::generation::sorting::module_specifiers::cmp_module_specifiers; + + let cmp = move |a_orig: usize, b_orig: usize| -> std::cmp::Ordering { + let sort = context.config.module_sort_import_declarations; + if sort == SortOrder::Maintain { + return a_orig.cmp(&b_orig); + } + let src_a = node_src_with_quotes(&g.nodes[a_orig], context); + let src_b = node_src_with_quotes(&g.nodes[b_orig], context); + match sort { + SortOrder::CaseSensitive => cmp_module_specifiers(&src_a, &src_b, |x, y| x.cmp(y)), + SortOrder::CaseInsensitive => cmp_module_specifiers(&src_a, &src_b, |x, y| x.to_lowercase().cmp(&y.to_lowercase())), + SortOrder::Maintain => unreachable!(), + } + }; +``` + +`node_src_with_quotes` helper (add near `partition_indices` usage): + +```rust +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 the surrounding quotes. + d.src.text_fast(context.program).to_string() + } else { + String::new() + } +} +``` + +NOTE: this duplicates the existing sorter behavior found via +`get_node_sorter_from_order` — when the existing sorter at the end of +`gen_statements` runs, it will reorder *again* if we don't disable it for +import groups when the feature is on. Disable it: in `get_node_sorter` +(around line 7382), short-circuit when `subgroup_boundaries.is_some()`: + +```rust + fn get_node_sorter<'a>( + group_kind: StmtGroupKind, + stmt_group: &StmtGroup<'a>, + context: &Context<'a>, + ) -> Option<...> { + if stmt_group.subgroup_boundaries.is_some() { + return None; // Already sorted by partitioner. + } + match group_kind { ... } + } +``` + +Update the call site of `get_node_sorter` to pass `&stmt_group`. + +- [ ] **Step 2: Build** + +```bash +cargo build --lib +``` + +Expected: compiles. + +- [ ] **Step 3: Spec test for within-group sort** + +Append to `ImportGroups_Basic.txt`: + +``` +== with caseInsensitive sort within each group == +~~ lineWidth: 80, module.importGroups: [{"match":"external"},{"match":["sibling","index"]}], module.sortImportDeclarations: caseInsensitive ~~ +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"; +``` + +(Adjust the spec format if the harness expects a single config header per +file — split into two files if needed.) + +- [ ] **Step 4: Run** + +```bash +cargo test --test specs declarations::import::ImportGroups_Basic +``` + +Expected: passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/generation/generate.rs tests/specs/declarations/import/ImportGroups_Basic.txt +git commit -m "feat(imports): within-subgroup sort honors module.sortImportDeclarations" +``` + +### Task 6.2: Spec coverage for type-only modes + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_TypeImports.txt` + +- [ ] **Step 1: Write spec file with 4 sub-tests** + +``` +~~ lineWidth: 80, module.importGroups: [{"match":"external"},{"match":"type"}], module.sortImportDeclarations: caseInsensitive ~~ +== typeImports separate (default): pulls import type into type 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 + type specifier stays value (no type-only flag on decl) == +import Foo, { type Bar } from "alpha"; +import type { Baz } from "beta"; + +[expect] +import Foo, { type Bar } from "alpha"; + +import type { Baz } from "beta"; +``` + +``` +~~ lineWidth: 80, module.importGroups: [{"match":"external"}], module.typeImports: interleave ~~ +== typeImports interleave: type and value imports mix in the 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"; +``` + +(Split into two files if the spec runner requires a single header.) + +- [ ] **Step 2: Run** + +```bash +cargo test --test specs declarations::import::ImportGroups_TypeImports +``` + +Expected: passes. + +- [ ] **Step 3: Commit** + +```bash +git add tests/specs/declarations/import/ImportGroups_TypeImports.txt +git commit -m "test(imports): coverage for typeImports separate/interleave modes" +``` + +### Task 6.3: Spec coverage for builtinsRuntime + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Node.txt` +- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Deno.txt` +- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Bun.txt` +- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_None.txt` + +- [ ] **Step 1: Write four spec files** + +For each runtime, write one spec where the input contains: + +``` +import fs from "fs"; +import { test } from "bun:test"; +import { x } from "node:path"; +import { y } from "npm:react"; +import { z } from "react"; +``` + +and the expected output groups them per the runtime table: + +| Runtime | `fs` | `bun:test` | `node:path` | `npm:react` | `react` | +|---|---|---|---|---|---| +| node | builtin | unknown* | builtin | unknown* | external | +| deno | external | external | builtin | external | external | +| bun | builtin | builtin | builtin | external | external | +| none | external | external | external | external | external | + +(*Under `node`, `bun:test` and `npm:react` aren't recognized → external. +Under `deno`/`bun`, `npm:react` is just external by virtue of not matching +any builtin rule.) + +Use config: + +``` +module.importGroups: [{"match":"builtin"},{"match":"external"}], module.builtinsRuntime: , module.sortImportDeclarations: caseInsensitive +``` + +- [ ] **Step 2: Run** + +```bash +cargo test --test specs declarations::import::ImportGroups_BuiltinsRuntime +``` + +Expected: 4 spec files pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_*.txt +git commit -m "test(imports): coverage for module.builtinsRuntime values" +``` + +### Task 6.4: Spec coverage for pattern groups and first-match-wins + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_Patterns.txt` + +- [ ] **Step 1: Write spec** + +``` +~~ lineWidth: 80, module.importGroups: [{"match":"external"},{"match":{"pattern":"@app/**"}},{"match":"parent"}], module.sortImportDeclarations: caseInsensitive ~~ +== pattern group positioned after external == +import { c } from "@app/foo"; +import { a } from "react"; +import { b } from "../shared"; + +[expect] +import { a } from "react"; + +import { c } from "@app/foo"; + +import { b } from "../shared"; + +~~ lineWidth: 80, module.importGroups: [{"match":{"pattern":"@app/**"}},{"match":"external"}], module.sortImportDeclarations: caseInsensitive ~~ +== pattern group positioned before external == +import { c } from "@app/foo"; +import { a } from "react"; + +[expect] +import { c } from "@app/foo"; + +import { a } from "react"; +``` + +- [ ] **Step 2: Run + commit** + +```bash +cargo test --test specs declarations::import::ImportGroups_Patterns +git add tests/specs/declarations/import/ImportGroups_Patterns.txt +git commit -m "test(imports): pattern matchers + first-match-wins ordering" +``` + +### Task 6.5: Spec coverage for side-effect barriers, `// dprint-ignore`, header comments + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_Barriers.txt` + +- [ ] **Step 1: Write tests** + +Each test uses the ESLint mirror config. Cover: + +- Side-effect import in the middle of a run: imports above & below are + grouped independently; the side-effect stays put with no reorder around it. +- `// dprint-ignore` on a single import: it stays in source position, acts + as barrier. +- License header (`/* @license */`) followed by blank line then imports: + header pinned to file start; imports reordered below it. +- `// @ts-check` shebang-style on first line: preserved. + +(Write 4 sub-tests with concrete inputs and expecteds. Use existing test +files for tone/format.) + +- [ ] **Step 2: Run + commit** + +```bash +cargo test --test specs declarations::import::ImportGroups_Barriers +git add tests/specs/declarations/import/ImportGroups_Barriers.txt +git commit -m "test(imports): side-effect barrier, dprint-ignore, header comment cases" +``` + +--- + +## Phase 7: Header-comment pinning + +### Task 7.1: Detect detached file-leading comments and pin them + +**Files:** +- Modify: `src/generation/generate.rs` + +- [ ] **Step 1: Identify how leading comments attach to first import today** + +In `gen_statements` (the loop that processes statements), the first node's +leading comments come via `node.leading_comments_fast(context.program)`. +After reorder, if a non-first import becomes first, its leading comments +travel with it — which is what we want for *attached* comments but not for +*detached* file-header comments. + +- [ ] **Step 2: Build a "header bag"** + +Before the partition step in `get_stmt_groups` (the new code added in +Task 5.3), introduce: + +```rust +fn split_header_comments<'a>( + first_import: &Node<'a>, + context: &Context<'a>, +) -> Vec { + // Take leading comments of the first import. Walk backward from the import + // start, collecting consecutive comments. A comment is "detached" if there + // is a blank line between it and the import (i.e., comment.hi_line + 2 <= + // import.start_line). + let comments = first_import.leading_comments_fast(context.program); + let import_start_line = first_import.start_line_fast(context.program); + let mut detached = Vec::new(); + for c in comments.into_iter() { + let c_end_line = c.end_line_fast(context.program); + if c_end_line + 1 < import_start_line { + detached.push(c.clone()); + } + } + detached +} +``` + +(`end_line_fast` may need adaptation depending on the comment helper API. +Use the existing helpers in `src/generation/comments.rs` for guidance.) + +- [ ] **Step 3: Emit detached comments as the very first items of the import block** + +Inside the partition branch added in Task 5.3, before reordering nodes: + +```rust + let detached = if let Some(first) = g.nodes.first() { + split_header_comments(first, context) + } else { Vec::new() }; + // Stash detached comments in the StmtGroup for emission at index 0. + g.detached_header_comments = detached; +``` + +Add a `detached_header_comments: Vec` field on `StmtGroup` (use +the same comment type as the SWC `comments::Comment`). + +In the emission loop, *before* the first node, emit those detached comments +verbatim and suppress them from the first node's `leading_comments_fast` +result. The suppression can be done by tracking a `HashSet` of +already-emitted comment positions on the `Context` for the duration of the +group emission. + +- [ ] **Step 4: Run header-comment spec test from Task 6.5** + +```bash +cargo test --test specs declarations::import::ImportGroups_Barriers +``` + +Expected: now passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/generation/generate.rs +git commit -m "feat(imports): pin detached file-header comments above first import after reorder" +``` + +--- + +## Phase 8: Merge pass (`module.mergeImports: true`) + +### Task 8.1: Eligibility check + +**Files:** +- Modify: `src/generation/imports/merge.rs` + +- [ ] **Step 1: Write a pure eligibility test** + +```rust +//! Merge pass for `module.mergeImports: true`. + +/// A simplified, pure model of merge eligibility for testing. The real entry +/// point operates on `ImportDecl` nodes; this struct lets us unit-test the +/// rules without an AST. +#[derive(Clone)] +pub struct MergeCandidate { + pub src: String, + pub attrs: Option, // canonicalized attribute fingerprint + pub has_default: bool, + pub default_name: Option, + pub has_ignore_comment: bool, +} + +pub fn can_merge(a: &MergeCandidate, b: &MergeCandidate) -> bool { + if a.src != b.src { return false; } + if a.attrs != b.attrs { return false; } + if a.has_ignore_comment || b.has_ignore_comment { return false; } + if a.has_default && b.has_default && a.default_name != b.default_name { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cand(src: &str) -> MergeCandidate { + MergeCandidate { + src: src.to_string(), + attrs: None, + has_default: false, + default_name: None, + has_ignore_comment: false, + } + } + + #[test] + fn same_src_no_default_merges() { + assert!(can_merge(&cand("./x"), &cand("./x"))); + } + + #[test] + fn different_src_blocks() { + assert!(!can_merge(&cand("./x"), &cand("./y"))); + } + + #[test] + fn conflicting_defaults_block() { + let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; + let b = MergeCandidate { has_default: true, default_name: Some("Bar".into()), ..cand("x") }; + assert!(!can_merge(&a, &b)); + } + + #[test] + fn same_default_merges() { + let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; + let b = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; + assert!(can_merge(&a, &b)); + } + + #[test] + fn different_attrs_block() { + let mut a = cand("x"); + a.attrs = Some("type=json".into()); + let mut b = cand("x"); + b.attrs = Some("type=css".into()); + assert!(!can_merge(&a, &b)); + } + + #[test] + fn dprint_ignore_blocks() { + let mut a = cand("x"); + a.has_ignore_comment = true; + assert!(!can_merge(&a, &cand("x"))); + } +} +``` + +- [ ] **Step 2: Run** + +```bash +cargo test --lib generation::imports::merge +``` + +Expected: 6 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/generation/imports/merge.rs +git commit -m "feat(imports): merge eligibility predicate" +``` + +### Task 8.2: AST-level merge synthesis + +**Files:** +- Modify: `src/generation/imports/merge.rs` +- Modify: `src/generation/generate.rs` + +This is the most involved task because it produces a new `PrintItems` chunk +representing the merged declaration, since dprint can't mutate the AST. + +- [ ] **Step 1: Design the entry point** + +In `merge.rs`, add: + +```rust +use deno_ast::view::*; +use crate::generation::context::Context; +use dprint_core::formatting::PrintItems; + +/// Given a contiguous run of import decls already classified into the same +/// subgroup and sorted by within-group order, return: +/// - a Vec where each bucket is either a single decl (unmerged) +/// or a list of decls to be emitted as one merged declaration. +pub enum MergeBucket<'a> { + Single(&'a ImportDecl<'a>), + Merged(Vec<&'a ImportDecl<'a>>), +} + +pub fn build_buckets<'a>( + decls: &[&'a ImportDecl<'a>], + context: &Context<'a>, +) -> Vec>; + +/// Generate the print items for a merged group of decls. Synthesises a +/// single declaration with the union of specifiers, defaults-first ordering, +/// type markers preserved, and concatenated leading comments. +pub fn gen_merged( + decls: &[&ImportDecl], + context: &mut Context, +) -> PrintItems; +``` + +- [ ] **Step 2: Implement `build_buckets` (pure)** + +Walk the slice; for each pair of adjacent decls, call `can_merge` (after +building a `MergeCandidate` from the `ImportDecl`). Extend the current +bucket if eligible, else start a new one. + +Add a helper: + +```rust +fn candidate_for<'a>(decl: &'a ImportDecl<'a>, context: &Context<'a>) -> MergeCandidate { + // src + let src = decl.src.value().to_string(); + // attrs: canonicalize to sorted key=value pairs + let attrs = decl.with.as_ref().map(|w| { + let mut pairs: Vec<(String, String)> = w.props.iter().filter_map(|p| { + // Only ImportAttribute pairs; if SWC view exposes them differently, adapt. + match p { /* ImportAttribute kv */ _ => None } + }).collect(); + pairs.sort(); + pairs.into_iter().map(|(k, v)| format!("{k}={v}")).collect::>().join(",") + }); + // default specifier + let default_spec = decl.specifiers.iter().find_map(|s| match s { + ImportSpecifier::Default(d) => Some(d.local.sym().to_string()), + _ => None, + }); + // dprint-ignore + let has_ignore = context.has_ignore_comment(&decl.range()); + MergeCandidate { + src, + attrs, + has_default: default_spec.is_some(), + default_name: default_spec, + has_ignore_comment: has_ignore, + } +} +``` + +(`has_ignore_comment` may be a helper on `Context`; see +`src/utils/file_text_has_ignore_comment.rs` for the existing pattern.) + +- [ ] **Step 3: Implement `gen_merged`** + +```rust +pub fn gen_merged( + decls: &[&ImportDecl], + context: &mut Context, +) -> PrintItems { + // Union of specifiers. + let mut default_spec: Option<&ImportDefaultSpecifier> = None; + let mut namespace_spec: Option<&ImportStarAsSpecifier> = None; + let mut named: Vec<(&ImportNamedSpecifier, bool /* is_type */)> = Vec::new(); + + for d in decls { + for s in d.specifiers.iter() { + match s { + ImportSpecifier::Default(x) => { + if default_spec.is_none() { default_spec = Some(x); } + } + ImportSpecifier::Namespace(x) => { + if namespace_spec.is_none() { namespace_spec = Some(x); } + } + ImportSpecifier::Named(x) => { + let is_type = d.type_only() || x.is_type_only(); + named.push((x, is_type)); + } + } + } + } + + // Sort named per importDeclaration.sortNamedImports. + // (Reuse the existing helper that emits a named-imports block, but feed + // it the merged specifier list. The simplest approach is to synthesize a + // string for the merged declaration here and call back into the existing + // generator. Concretely: build a textual ImportDecl source, then reparse + // — but that loses comments. Instead, manually build PrintItems mirroring + // gen_import_decl's structure with our merged specifier list.) + + // For v1 of merge, implement the simple text-emission path: + // - emit leading comments (concatenation from all merged decls). + // - emit `import` keyword. + // - if any merged decl was fully type-only, decide: if ALL decls are + // type-only, emit `import type {...}`; else emit value form and tag + // individual specifiers with `type`. + // - emit default + namespace + named. + // - emit `from ""`. + // - emit attrs if any (must all be equal — eligibility ensured this). + // - emit semicolon per existing config. + + let all_type_only = decls.iter().all(|d| d.type_only()); + + // Reuse the existing helpers from src/generation/generate.rs that emit + // the keyword + specifier list + `from "src"` for an ImportDecl. The + // cleanest mechanism is to pick one of the merged decls (the first) as + // the "host" and call `gen_import_decl(host, context)` after temporarily + // swapping its specifier list with the merged specifier list. + // + // Since the SWC view nodes are immutable, the actual approach is: + // 1. Build a synthetic source string for the merged declaration: + // `import , * as , { a, type B, c } from "";` + // Pick defaults/namespace/named from the union built above. + // 2. Parse it with deno_ast::parse_module (a single decl). + // 3. Generate via gen_import_decl over the parsed synthetic node. + // 4. Prepend concatenated leading comments from the merged decls. + // + // Use synthesize_merged_source(decls) and parse_synthetic_decl(src) as + // helpers to keep gen_merged short. Tests in Task 8.3 verify roundtrip. + let synth_src = synthesize_merged_source(decls, default_spec, namespace_spec, &named, all_type_only); + let synth_decl = parse_synthetic_decl(&synth_src, context); + let mut items = PrintItems::new(); + items.extend(concatenated_leading_comments(decls, context)); + items.extend(crate::generation::generate::gen_import_decl(&synth_decl, context)); + items +} +``` + +The three helpers (`synthesize_merged_source`, `parse_synthetic_decl`, +`concatenated_leading_comments`) are private to `merge.rs`. Sketch: + +```rust +fn synthesize_merged_source( + decls: &[&ImportDecl], + default: Option<&ImportDefaultSpecifier>, + namespace: Option<&ImportStarAsSpecifier>, + named: &[(&ImportNamedSpecifier, bool)], + all_type_only: bool, +) -> String { + let mut out = String::from("import "); + if all_type_only { out.push_str("type "); } + let mut parts: Vec = Vec::new(); + if let Some(d) = default { parts.push(d.local.sym().to_string()); } + if let Some(ns) = namespace { parts.push(format!("* as {}", ns.local.sym())); } + if !named.is_empty() { + let inner: Vec = named.iter().map(|(n, is_type)| { + let prefix = if *is_type && !all_type_only { "type " } else { "" }; + match &n.imported { + Some(orig) => format!("{prefix}{} as {}", orig.sym(), n.local.sym()), + None => format!("{prefix}{}", n.local.sym()), + } + }).collect(); + parts.push(format!("{{ {} }}", inner.join(", "))); + } + out.push_str(&parts.join(", ")); + out.push_str(&format!(" from {:?}", decls[0].src.value())); + // Attributes: eligibility ensured all equal, so take from first. + if let Some(attrs_text) = serialize_attrs(decls[0]) { + out.push_str(&format!(" with {}", attrs_text)); + } + out.push(';'); + out +} + +fn parse_synthetic_decl<'a>(src: &str, context: &Context<'a>) -> ImportDecl<'a> { + // Use deno_ast::parse_module with the same syntax flags as the host + // program. Pull the first item, downcast to ImportDecl. The parsed + // module's lifetime is unrelated to 'a — store it on Context's arena + // (add a Vec field for synthetic merges). + todo!("see comments in this file for the arena trick") +} +``` + +The arena trick: add a `synthetic_arena: Vec` to +`Context` so synthetic decls live long enough. Push the parsed module into +the arena, return a reference to the parsed `ImportDecl`. + +**Acknowledged complexity:** this task is intentionally larger than the +2–5 minute target. Budget ~half a day. If the engineer hits trouble with +the arena/lifetime dance, consider falling back to hand-building +`PrintItems` directly using helpers from `gen_import_decl` for the named +specifier block — but the synthetic-parse path keeps comment and attribute +handling consistent with the rest of dprint-plugin-typescript. + +- [ ] **Step 4: Wire `build_buckets` into the partition emission** + +In `gen_statements` (the loop), when iterating an Imports group with +`subgroup_boundaries.is_some()` and `context.config.module_merge_imports`, +walk each subgroup's slice through `build_buckets`. For each: +- `Single(d)`: emit via existing `gen_node(*d, context)`. +- `Merged(ds)`: emit via `gen_merged(ds, context)`. + +- [ ] **Step 5: Add spec for basic merge** + +`tests/specs/declarations/import/ImportGroups_Merge_Basic.txt`: + +``` +~~ lineWidth: 80, module.importGroups: [{"match":"external"}], module.sortImportDeclarations: maintain, module.mergeImports: true ~~ +== merges two imports from same source == +import { a } from "x"; +import { b } from "x"; + +[expect] +import { a, b } from "x"; +``` + +- [ ] **Step 6: Run** + +```bash +cargo test --test specs declarations::import::ImportGroups_Merge +``` + +Expected: passes for the basic case. + +- [ ] **Step 7: Commit** + +```bash +git add src/generation/imports/merge.rs src/generation/generate.rs tests/specs/declarations/import/ImportGroups_Merge_Basic.txt +git commit -m "feat(imports): merge multiple imports from same source when enabled" +``` + +### Task 8.3: Merge edge-case specs + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_Merge_*.txt` (six files) + +- [ ] **Step 1: Write specs for** + +1. Side-effect + named → merged to named: `import "./x"; import { a } from "./x";` → `import { a } from "./x";`. +2. Default + namespace → `import x, * as y from "z"`. +3. Value + type-only → `import { a, type B } from "x"`. +4. All type-only → `import type { A, B } from "x"`. +5. Conflicting defaults → both kept, diagnostic in `r.diagnostics`. +6. Different `with { ... }` attrs → both kept. + +- [ ] **Step 2: Run + commit** + +```bash +cargo test --test specs declarations::import::ImportGroups_Merge +git add tests/specs/declarations/import/ImportGroups_Merge_*.txt +git commit -m "test(imports): merge edge cases (side-effect, type, conflicts, attrs)" +``` + +--- + +## Phase 9: Diagnostics & invalid configs + +### Task 9.1: Unknown category string diagnostic + +**Files:** +- Modify: `src/configuration/resolve_config.rs` + +- [ ] **Step 1: Add a test** + +In `import_groups_resolution_tests`: + +```rust +#[test] +fn unknown_category_string_diagnostic() { + let r = resolve(serde_json::json!({ + "module.importGroups": [{ "match": "buildin" }] + })); + assert!(!r.diagnostics.is_empty()); + assert!(r.diagnostics[0].message.contains("buildin")); +} +``` + +- [ ] **Step 2: Update `parse_import_groups` to validate string categories** + +After serde deserialization succeeds, walk each `ImportMatcher::Category` +value and verify it's a known variant. Since the enum is closed at the +serde level, an unknown variant currently errors out at deserialization — +test it surfaces the variant name in the error message. + +Alternatively: relax the enum to `Category(String)` for parsing, and +validate in `compile`. + +- [ ] **Step 3: Verify the test passes** + +```bash +cargo test --lib import_groups_resolution_tests +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/configuration/resolve_config.rs +git commit -m "feat(config): diagnostic for unknown category strings in module.importGroups" +``` + +### Task 9.2: `type` listed under `typeImports: interleave` diagnostic + +**Files:** +- Modify: `src/generation/imports/resolved.rs` + +- [ ] **Step 1: Add test** + +```rust +#[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"))); +} +``` + +- [ ] **Step 2: Implement** + +In `compile`, when iterating matchers, if `c == BuiltinCategory::Type` and +`config.module_type_imports == TypeImportsMode::Interleave`, push a diagnostic +and skip. + +- [ ] **Step 3: Run + commit** + +```bash +cargo test --lib generation::imports::resolved +git add src/generation/imports/resolved.rs +git commit -m "feat(imports): diagnostic for \"type\" group under typeImports=interleave" +``` + +### Task 9.3: Bubble compile diagnostics into resolve_config diagnostics list + +**Files:** +- Modify: `src/configuration/resolve_config.rs` +- Modify: `src/generation/generate.rs` + +- [ ] **Step 1: Move compile out of `gen_program`** + +Move the call to `compile(...)` from `gen_program` (added in Task 5.3) into +`resolve_config` so diagnostics surface through the normal mechanism. Cache +the result on the resolved config (e.g. via a new field +`resolved_import_groups: Option`). + +Adjust `Configuration` to either hold the resolved groups or to be paired +with a `ResolvedConfiguration` wrapper. The simplest path: + +- Keep `module_import_groups: Vec` on `Configuration` (the + serialized form). +- Add a non-serialized `#[serde(skip)] pub resolved_import_groups: Option` on `Configuration`. +- Populate during `resolve_config`. +- Generation reads from `context.config.resolved_import_groups`. + +- [ ] **Step 2: Wire diagnostics** + +In `parse_import_groups` (or a new function called immediately after), call +`compile` and append any returned diagnostic strings to the +`diagnostics: &mut Vec` with property name +`"module.importGroups"`. + +- [ ] **Step 3: Run all unit + spec tests** + +```bash +cargo test +``` + +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git add src/configuration/resolve_config.rs src/generation/generate.rs +git commit -m "refactor(config): compile import groups during resolve, bubble diagnostics" +``` + +--- + +## Phase 10: Remaining spec coverage + +### Task 10.1: Catch-all and unknown position + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_Unknown.txt` + +- [ ] **Step 1: Tests** + +- Implicit catch-all at end (when no `unknown` listed): unmatched import + appears in a final group. +- Explicit `unknown` placement: place `{ "match": "unknown" }` at the + beginning; unmatched imports appear first. + +- [ ] **Step 2: Run + commit** + +```bash +cargo test --test specs declarations::import::ImportGroups_Unknown +git add tests/specs/declarations/import/ImportGroups_Unknown.txt +git commit -m "test(imports): implicit and explicit unknown group placement" +``` + +### Task 10.2: Multi-chunk + non-import barrier + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_MultiChunk.txt` + +Cover: imports, then non-import statement, then more imports. Each chunk +grouped independently; no cross-chunk reorder. + +- [ ] Run + commit. + +### Task 10.3: Import attributes + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_Attributes.txt` + +Cover: `import x from "y" with { type: "json" }`. Classified by path, +attribute preserved. With `mergeImports: true`, two such imports with +different attribute values don't merge. + +- [ ] Run + commit. + +### Task 10.4: `.d.ts` and `declare module` + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_DeclarationFiles.txt` + +Cover: `.d.ts` file with imports → grouped; imports inside `declare module +"foo" { ... }` → untouched. + +- [ ] Run + commit. + +### Task 10.5: Interaction with existing knobs + +**Files:** +- Create: `tests/specs/declarations/import/ImportGroups_KnobInteractions.txt` + +Cover: +- `importDeclaration.forceSingleLine: true` with grouping: declarations + still single-lined per knob. +- `importDeclaration.sortNamedImports: caseInsensitive` with grouping: each + decl's specifier list still sorted. +- `module.sortImportDeclarations: maintain` with grouping: within-group + order preserved; only cross-group reorder happens. + +- [ ] Run + commit. + +### Task 10.6: Idempotence guarantee + +**Files:** none. + +- [ ] **Step 1: Confirm `format_twice: true` in spec_test.rs** + +`tests/spec_test.rs` already passes `format_twice: true`, so all specs +already verify idempotence. If any spec breaks under second-pass formatting, +treat it as a feature bug: classification must produce the same output on +already-formatted input. + +```bash +cargo test --test specs +``` + +Expected: all pass under double-format. + +--- + +## Phase 11: Documentation + +### Task 11.1: README + JSON schema + +**Files:** +- Modify: `README.md` +- Modify: `deployment/schema.json` (if it exists) + +- [ ] **Step 1: README** + +Add a section "Import grouping" with the four config keys, a basic example, +and a migration table from ESLint `import/order`. Keep concise — link to the +design doc for the full spec. + +- [ ] **Step 2: schema.json** + +If `deployment/schema.json` exists, add entries for: +- `module.importGroups` — array of objects with `match`. +- `module.typeImports` — `"separate" | "interleave"`. +- `module.mergeImports` — boolean. +- `module.builtinsRuntime` — `"node" | "deno" | "bun" | "none"`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md deployment/schema.json +git commit -m "docs(imports): README and JSON schema for module.importGroups (#493)" +``` + +--- + +## Phase 12: Final verification + +### Task 12.1: Full test run + +- [ ] **Step 1: Run everything** + +```bash +cargo test --release +``` + +Expected: all unit + spec tests pass. + +- [ ] **Step 2: Compare against baseline-pre-import-groups for feature-off identity** + +```bash +git diff baseline-pre-import-groups -- tests/specs/ # any unintended changes? +``` + +Expected: only new spec files added under `tests/specs/declarations/import/ImportGroups_*.txt`; no existing specs modified. + +- [ ] **Step 3: Clippy clean** + +```bash +cargo clippy --all-targets -- -D warnings +``` + +Expected: no warnings. + +- [ ] **Step 4: Format the repo with itself** + +```bash +cargo run -- fmt +``` + +(Or whatever dprint self-format invocation the repo uses — check `.github/` +or `scripts/`.) + +- [ ] **Step 5: Push branch + open draft PR** + +```bash +git push -u origin feat-import-groups +gh pr create --draft --title "feat: import grouping (#493)" --body "Closes #493. See docs/superpowers/specs/2026-05-21-import-groups-design.md." +``` + +--- + +## Out-of-scope reminders (do not implement here) + +- CJS `require(...)`, dynamic `import()`, TS `import = require()`. +- Module resolver / tsconfig paths. +- Natural sort, descending sort. +- Imports inside `declare module "..."` bodies (skipped by design). +- Webpack-style alias resolution beyond raw glob. + +If any of these come up during implementation, file an issue and move on. From be620ae2c77cb472c0888c0300222ccf2a5a8738 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:54:06 +0300 Subject: [PATCH 06/33] feat(config): add TypeImportsMode and BuiltinsRuntime enums --- src/configuration/types.rs | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/configuration/types.rs b/src/configuration/types.rs index 61b08bf7..26d1f707 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -307,6 +307,40 @@ 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"] +]; + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Configuration { @@ -665,3 +699,25 @@ pub struct Configuration { #[serde(rename = "whileStatement.spaceAround")] pub while_statement_space_around: bool, } + +#[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"); + } +} From f097b087336589fe1943a49a41943e46fe5b1141 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 20:59:32 +0300 Subject: [PATCH 07/33] feat(config): add ImportGroup, ImportMatcher, BuiltinCategory --- src/configuration/types.rs | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/configuration/types.rs b/src/configuration/types.rs index 26d1f707..ca21aa28 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -341,6 +341,54 @@ generate_str_to_from![ [None, "none"] ]; +/// Built-in category strings allowed in `module.importGroups[].match`. +#[derive(Clone, Copy, 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 { @@ -720,4 +768,23 @@ mod import_group_enum_tests { 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))); + } } From 6c6c94f2077d7781dcadabfe2c870334a4531cb0 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:05:17 +0300 Subject: [PATCH 08/33] feat(config): add module.importGroups/typeImports/mergeImports/builtinsRuntime fields --- src/configuration/resolve_config.rs | 4 ++++ src/configuration/types.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index ea393be3..e6ea26f0 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -132,6 +132,10 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) NamedTypeImportsExportsOrder::None, &mut diagnostics, ), + module_import_groups: Vec::new(), + module_type_imports: get_value(&mut config, "module.typeImports", TypeImportsMode::Separate, &mut diagnostics), + module_merge_imports: get_value(&mut config, "module.mergeImports", false, &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), diff --git a/src/configuration/types.rs b/src/configuration/types.rs index ca21aa28..501ae423 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -436,6 +436,14 @@ 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.mergeImports", default)] + pub module_merge_imports: bool, + #[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, @@ -748,6 +756,14 @@ pub struct Configuration { 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::*; From 8ced8fc55349bfda8d3ff14802cc65148567dbc6 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:09:59 +0300 Subject: [PATCH 09/33] feat(config): add builder methods for import grouping config --- Cargo.toml | 4 ++-- src/configuration/builder.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1bbec3c3..4c22af63 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]] @@ -38,7 +38,7 @@ dprint-core-macros = "0.1.0" percent-encoding = "2.3.1" 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/src/configuration/builder.rs b/src/configuration/builder.rs index bfa35df5..6c12eaed 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -576,6 +576,36 @@ 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()) + } + + /// Merge multiple imports from the same source into one declaration. + /// + /// Default: `false` + pub fn module_merge_imports(&mut self, value: bool) -> &mut Self { + self.insert("module.mergeImports", value.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`). From 8f7436e74f7ab6fbd1b6821cfd5bb13fda842018 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:14:05 +0300 Subject: [PATCH 10/33] feat(config): resolve module.importGroups and related keys --- src/configuration/resolve_config.rs | 75 ++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index e6ea26f0..dfb17af0 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -132,7 +132,7 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) NamedTypeImportsExportsOrder::None, &mut diagnostics, ), - module_import_groups: Vec::new(), + module_import_groups: parse_import_groups(&mut config, &mut diagnostics), module_type_imports: get_value(&mut config, "module.typeImports", TypeImportsMode::Separate, &mut diagnostics), module_merge_imports: get_value(&mut config, "module.mergeImports", false, &mut diagnostics), module_builtins_runtime: get_value(&mut config, "module.builtinsRuntime", BuiltinsRuntime::Node, &mut diagnostics), @@ -356,6 +356,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; @@ -416,3 +445,47 @@ 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!(!r.config.module_merge_imports); + 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"); + } +} From 3a0badcf4dd5891548f132da228962961976d27c Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:15:28 +0300 Subject: [PATCH 11/33] build: add globset and phf dependencies --- Cargo.lock | 31 +++++++++++++++++++++++++++++++ Cargo.toml | 2 ++ 2 files changed, 33 insertions(+) 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 4c22af63..bce6dd64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,9 @@ 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" } From ecd855a7cd1fe34ed46c6cdc5335866b79ec61b8 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:16:07 +0300 Subject: [PATCH 12/33] feat: add is_builtin classifier with Node/Deno/Bun runtimes --- src/utils/builtins.rs | 81 +++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 2 ++ 2 files changed, 83 insertions(+) create mode 100644 src/utils/builtins.rs 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::*; From bb12cd27ff10abf5a613209c9a742f541fe84d16 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:18:25 +0300 Subject: [PATCH 13/33] chore(imports): scaffold imports submodule tree --- src/generation/imports/classify.rs | 1 + src/generation/imports/merge.rs | 1 + src/generation/imports/mod.rs | 4 ++++ src/generation/imports/partition.rs | 1 + src/generation/imports/resolved.rs | 1 + src/generation/mod.rs | 2 ++ 6 files changed, 10 insertions(+) create mode 100644 src/generation/imports/classify.rs create mode 100644 src/generation/imports/merge.rs create mode 100644 src/generation/imports/mod.rs create mode 100644 src/generation/imports/partition.rs create mode 100644 src/generation/imports/resolved.rs diff --git a/src/generation/imports/classify.rs b/src/generation/imports/classify.rs new file mode 100644 index 00000000..26efabe5 --- /dev/null +++ b/src/generation/imports/classify.rs @@ -0,0 +1 @@ +//! placeholder, see Task 3.3 diff --git a/src/generation/imports/merge.rs b/src/generation/imports/merge.rs new file mode 100644 index 00000000..f1aaf206 --- /dev/null +++ b/src/generation/imports/merge.rs @@ -0,0 +1 @@ +//! placeholder, see Task 5.1 diff --git a/src/generation/imports/mod.rs b/src/generation/imports/mod.rs new file mode 100644 index 00000000..370b0f5e --- /dev/null +++ b/src/generation/imports/mod.rs @@ -0,0 +1,4 @@ +pub mod classify; +pub mod partition; +pub mod merge; +pub mod resolved; diff --git a/src/generation/imports/partition.rs b/src/generation/imports/partition.rs new file mode 100644 index 00000000..2f456c22 --- /dev/null +++ b/src/generation/imports/partition.rs @@ -0,0 +1 @@ +//! placeholder, see Task 4.1 diff --git a/src/generation/imports/resolved.rs b/src/generation/imports/resolved.rs new file mode 100644 index 00000000..2d37dbc5 --- /dev/null +++ b/src/generation/imports/resolved.rs @@ -0,0 +1 @@ +//! placeholder, see Task 3.2 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::*; From c06d926e027f580f45fa38870c6026be6ed6e3e9 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:19:07 +0300 Subject: [PATCH 14/33] feat(imports): compile import groups into resolved form --- src/configuration/types.rs | 2 +- src/generation/imports/resolved.rs | 153 ++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/configuration/types.rs b/src/configuration/types.rs index 501ae423..a32ab3cb 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -342,7 +342,7 @@ generate_str_to_from![ ]; /// Built-in category strings allowed in `module.importGroups[].match`. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum BuiltinCategory { Builtin, diff --git a/src/generation/imports/resolved.rs b/src/generation/imports/resolved.rs index 2d37dbc5..4ce457b4 100644 --- a/src/generation/imports/resolved.rs +++ b/src/generation/imports/resolved.rs @@ -1 +1,152 @@ -//! placeholder, see Task 3.2 +//! 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"))); + } +} From aa06aa374fd7f04c8fa245aa47f9ba0ab62a7c94 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:21:01 +0300 Subject: [PATCH 15/33] feat(imports): pure classifier of import sources into resolved groups --- src/generation/imports/classify.rs | 167 ++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/src/generation/imports/classify.rs b/src/generation/imports/classify.rs index 26efabe5..6227fd8e 100644 --- a/src/generation/imports/classify.rs +++ b/src/generation/imports/classify.rs @@ -1 +1,166 @@ -//! placeholder, see Task 3.3 +//! 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 { + if src == "." || src == "./" || src == "./index" { + return true; + } + for ext in [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] { + if src == format!("./index{ext}") { + return true; + } + } + false +} + +#[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); + } +} From 59a25ec0fcf82c3cf3d19daf592d6dbd2cf8306c Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:21:55 +0300 Subject: [PATCH 16/33] feat(imports): stable partition of classified imports --- src/generation/imports/partition.rs | 71 ++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/generation/imports/partition.rs b/src/generation/imports/partition.rs index 2f456c22..33fcac6f 100644 --- a/src/generation/imports/partition.rs +++ b/src/generation/imports/partition.rs @@ -1 +1,70 @@ -//! placeholder, see Task 4.1 +//! 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]); + } +} From 2ceaf0c3d5fdeaca6857e30c597e60d328f5d332 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:24:30 +0300 Subject: [PATCH 17/33] refactor(generate): add subgroup_boundaries to StmtGroup --- src/generation/generate.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 7fb3a332..670a6496 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7411,6 +7411,9 @@ 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>, } fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec> { @@ -7441,12 +7444,14 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec Date: Thu, 21 May 2026 21:31:08 +0300 Subject: [PATCH 18/33] feat(imports): partition imports during get_stmt_groups when enabled --- src/generation/context.rs | 5 +++++ src/generation/generate.rs | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/generation/context.rs b/src/generation/context.rs index 2f50f4c5..ea181936 100644 --- a/src/generation/context.rs +++ b/src/generation/context.rs @@ -77,6 +77,7 @@ pub struct Context<'a> { #[cfg(debug_assertions)] pub last_generated_node_pos: SourcePos, pub diagnostics: Vec, + pub resolved_import_groups: Option, } impl<'a> Context<'a> { @@ -88,6 +89,9 @@ 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); + // diagnostics dropped here for now; surfaced via resolve_config in a later task. Context { media_type, program, @@ -111,6 +115,7 @@ impl<'a> Context<'a> { #[cfg(debug_assertions)] last_generated_node_pos: deno_ast::SourceTextInfoProvider::text_info(&program).range().start.into(), diagnostics: Vec::new(), + resolved_import_groups, } } diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 670a6496..b6d74e6a 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7460,6 +7460,46 @@ fn get_stmt_groups<'a>(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(); + let (ordered, boundaries) = crate::generation::imports::partition::partition_indices( + &classified, + resolved.groups.len(), + |_a, _b| std::cmp::Ordering::Equal, // intra-group sort handled by existing sorter in gen_statements + ); + let mut new_nodes: Vec = Vec::with_capacity(g.nodes.len()); + for orig in &ordered { + new_nodes.push(g.nodes[*orig]); + } + g.nodes = new_nodes; + g.subgroup_boundaries = Some(boundaries); + } + } + groups } From 1ddfc2e45ea5188ff4b608056ad1f2e6a89e62b0 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:38:35 +0300 Subject: [PATCH 19/33] feat(imports): force blank line at subgroup boundary (#493) --- src/generation/generate.rs | 13 +++++++++-- .../import/ImportGroups_Basic.txt | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/specs/declarations/import/ImportGroups_Basic.txt diff --git a/src/generation/generate.rs b/src/generation/generate.rs index b6d74e6a..979b4c2a 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7298,7 +7298,11 @@ 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, @@ -7309,7 +7313,12 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & 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 let Some(boundaries) = stmt_group.subgroup_boundaries.as_ref() { + boundaries.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); 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"; From 6e19f316baf5705abe4707aa6ac3e0ac4e26544a Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:46:56 +0300 Subject: [PATCH 20/33] feat(imports): within-subgroup sort honors module.sortImportDeclarations --- src/generation/generate.rs | 26 ++++++++++++++++++- src/generation/sorting/mod.rs | 2 +- .../import/ImportGroups_Basic_Sorted.txt | 20 ++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/specs/declarations/import/ImportGroups_Basic_Sorted.txt diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 979b4c2a..d170088e 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7425,6 +7425,15 @@ struct StmtGroup<'a> { subgroup_boundaries: Option>, } +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> { let mut groups: Vec> = Vec::new(); let mut current_group: Option = None; @@ -7495,10 +7504,25 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> 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, _b| std::cmp::Ordering::Equal, // intra-group sort handled by existing sorter in gen_statements + |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], |x, y| x.to_lowercase().cmp(&y.to_lowercase())), + SortOrder::Maintain => unreachable!(), + } + }, ); let mut new_nodes: Vec = Vec::with_capacity(g.nodes.len()); for orig in &ordered { diff --git a/src/generation/sorting/mod.rs b/src/generation/sorting/mod.rs index 59a5b79a..3a4e7b36 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::*; 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"; From f6843f87c86766ff122cabdd92cea2cf3807d5dc Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 21:51:26 +0300 Subject: [PATCH 21/33] test(imports): coverage for typeImports, builtinsRuntime, patterns, barriers --- .../import/ImportGroups_Barriers.txt | 22 +++++++++++++++ .../ImportGroups_BuiltinsRuntime_Bun.txt | 23 ++++++++++++++++ .../ImportGroups_BuiltinsRuntime_Deno.txt | 23 ++++++++++++++++ .../ImportGroups_BuiltinsRuntime_Node.txt | 23 ++++++++++++++++ .../ImportGroups_BuiltinsRuntime_None.txt | 22 +++++++++++++++ .../import/ImportGroups_Patterns.txt | 19 +++++++++++++ .../import/ImportGroups_Patterns_Before.txt | 16 +++++++++++ .../ImportGroups_TypeImports_Interleave.txt | 16 +++++++++++ .../ImportGroups_TypeImports_Separate.txt | 27 +++++++++++++++++++ 9 files changed, 191 insertions(+) create mode 100644 tests/specs/declarations/import/ImportGroups_Barriers.txt create mode 100644 tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Bun.txt create mode 100644 tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Deno.txt create mode 100644 tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Node.txt create mode 100644 tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_None.txt create mode 100644 tests/specs/declarations/import/ImportGroups_Patterns.txt create mode 100644 tests/specs/declarations/import/ImportGroups_Patterns_Before.txt create mode 100644 tests/specs/declarations/import/ImportGroups_TypeImports_Interleave.txt create mode 100644 tests/specs/declarations/import/ImportGroups_TypeImports_Separate.txt diff --git a/tests/specs/declarations/import/ImportGroups_Barriers.txt b/tests/specs/declarations/import/ImportGroups_Barriers.txt new file mode 100644 index 00000000..b1ed774d --- /dev/null +++ b/tests/specs/declarations/import/ImportGroups_Barriers.txt @@ -0,0 +1,22 @@ +~~ { + "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"; 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_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"; From 81632b11fb49867c6af69b2fde7f7f10e8284b37 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:09:35 +0300 Subject: [PATCH 22/33] feat(imports): comments travel with imports on reorder; pin detached headers to file start --- src/generation/generate.rs | 164 ++++++++++++++++-- .../import/ImportGroups_Barriers.txt | 11 ++ .../import/ImportGroups_HeaderComment.txt | 20 +++ 3 files changed, 180 insertions(+), 15 deletions(-) create mode 100644 tests/specs/declarations/import/ImportGroups_HeaderComment.txt diff --git a/src/generation/generate.rs b/src/generation/generate.rs index d170088e..11e98805 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -6747,6 +6747,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 +6958,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 +6975,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 +7318,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()); @@ -7327,6 +7375,17 @@ 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. These were captured BEFORE the partition + // mutated `nodes`; look them up by ORIGINAL source index. + if let Some(source_index_for) = &stmt_group.source_index_for { + let src_idx = source_index_for[i]; + if let Some(comments) = stmt_group.captured_attached_leading.get(src_idx) { + 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); @@ -7423,6 +7482,17 @@ struct StmtGroup<'a> { /// 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>, + /// `source_index_for[post_reorder_position] = original source index`. + /// Used to look up captured comments during emission. + source_index_for: Option>, } fn node_src_with_quotes<'a>(node: &Node<'a>, context: &Context<'a>) -> String { @@ -7463,6 +7533,9 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> 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); + // Find longest suffix of `comments` where consecutive lines have no + // blank-line gap between them or between the last one and the node. + 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..].iter().copied().collect()); + } 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(g.nodes.len()); for orig in &ordered { new_nodes.push(g.nodes[*orig]); } g.nodes = new_nodes; g.subgroup_boundaries = Some(boundaries); + g.source_index_for = Some(ordered.clone()); + g.captured_attached_leading = captured_attached_leading; + g.captured_detached_header = captured_detached_header; } } + // Restore the resolved groups so later code paths still see them. + context.resolved_import_groups = resolved_opt; groups } diff --git a/tests/specs/declarations/import/ImportGroups_Barriers.txt b/tests/specs/declarations/import/ImportGroups_Barriers.txt index b1ed774d..850c91fb 100644 --- a/tests/specs/declarations/import/ImportGroups_Barriers.txt +++ b/tests/specs/declarations/import/ImportGroups_Barriers.txt @@ -20,3 +20,14 @@ 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_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"; From 38eda772c7f3e38809708449fd6a45642e7ef110 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:10:59 +0300 Subject: [PATCH 23/33] feat(imports): merge eligibility predicate --- src/generation/imports/merge.rs | 80 ++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/generation/imports/merge.rs b/src/generation/imports/merge.rs index f1aaf206..08115f25 100644 --- a/src/generation/imports/merge.rs +++ b/src/generation/imports/merge.rs @@ -1 +1,79 @@ -//! placeholder, see Task 5.1 +//! Merge pass for `module.mergeImports: true`. + +/// A pure model of merge eligibility used in unit tests. The real entry +/// point operates on `ImportDecl` nodes (added in Task 8.2); this struct lets +/// us unit-test the rules without an AST. +#[derive(Clone)] +pub struct MergeCandidate { + pub src: String, + /// Canonicalized attribute fingerprint (`None` if no `with { ... }`). + pub attrs: Option, + pub has_default: bool, + pub default_name: Option, + pub has_ignore_comment: bool, +} + +pub fn can_merge(a: &MergeCandidate, b: &MergeCandidate) -> bool { + if a.src != b.src { return false; } + if a.attrs != b.attrs { return false; } + if a.has_ignore_comment || b.has_ignore_comment { return false; } + if a.has_default && b.has_default && a.default_name != b.default_name { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cand(src: &str) -> MergeCandidate { + MergeCandidate { + src: src.to_string(), + attrs: None, + has_default: false, + default_name: None, + has_ignore_comment: false, + } + } + + #[test] + fn same_src_no_default_merges() { + assert!(can_merge(&cand("./x"), &cand("./x"))); + } + + #[test] + fn different_src_blocks() { + assert!(!can_merge(&cand("./x"), &cand("./y"))); + } + + #[test] + fn conflicting_defaults_block() { + let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; + let b = MergeCandidate { has_default: true, default_name: Some("Bar".into()), ..cand("x") }; + assert!(!can_merge(&a, &b)); + } + + #[test] + fn same_default_merges() { + let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; + let b = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; + assert!(can_merge(&a, &b)); + } + + #[test] + fn different_attrs_block() { + let mut a = cand("x"); + a.attrs = Some("type=json".into()); + let mut b = cand("x"); + b.attrs = Some("type=css".into()); + assert!(!can_merge(&a, &b)); + } + + #[test] + fn dprint_ignore_blocks() { + let mut a = cand("x"); + a.has_ignore_comment = true; + assert!(!can_merge(&a, &cand("x"))); + } +} From b538ed64451575c5c16128ca5a1548dc39501e25 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:17:07 +0300 Subject: [PATCH 24/33] feat(imports): merge bucket detection (full merge emission TBD) --- src/generation/imports/merge.rs | 100 +++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/generation/imports/merge.rs b/src/generation/imports/merge.rs index 08115f25..19308906 100644 --- a/src/generation/imports/merge.rs +++ b/src/generation/imports/merge.rs @@ -3,7 +3,7 @@ /// A pure model of merge eligibility used in unit tests. The real entry /// point operates on `ImportDecl` nodes (added in Task 8.2); this struct lets /// us unit-test the rules without an AST. -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq)] pub struct MergeCandidate { pub src: String, /// Canonicalized attribute fingerprint (`None` if no `with { ... }`). @@ -77,3 +77,101 @@ mod tests { assert!(!can_merge(&a, &cand("x"))); } } + +/// Index-based bucket: either a single decl at index `i` or a merge of multiple decls. +#[derive(Debug, PartialEq)] +pub enum MergeBucket { + Single(usize), + Merged(Vec), +} + +/// Compute merge buckets over a slice of decl metadata, in order. +/// MVP: only named-only imports from the same source merge. +pub fn compute_buckets(candidates: &[MergeCandidate], has_namespace: &[bool], has_named: &[bool]) -> Vec { + let mut buckets: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + for i in 0..candidates.len() { + // MVP: only allow named-only imports from same source to merge. + let mvp_eligible = !candidates[i].has_default + && !has_namespace[i] + && candidates[i].attrs.is_none() + && !candidates[i].has_ignore_comment + && has_named[i]; + + if !mvp_eligible { + flush_current(&mut buckets, &mut current); + buckets.push(MergeBucket::Single(i)); + continue; + } + + if current.is_empty() { + current.push(i); + continue; + } + let last = *current.last().unwrap(); + if can_merge(&candidates[last], &candidates[i]) { + current.push(i); + } else { + flush_current(&mut buckets, &mut current); + current.push(i); + } + } + flush_current(&mut buckets, &mut current); + buckets +} + +fn flush_current(buckets: &mut Vec, current: &mut Vec) { + match current.len() { + 0 => {} + 1 => buckets.push(MergeBucket::Single(current[0])), + _ => buckets.push(MergeBucket::Merged(std::mem::take(current))), + } + current.clear(); +} + +#[cfg(test)] +mod bucket_tests { + use super::*; + + fn c(src: &str) -> MergeCandidate { + MergeCandidate { + src: src.to_string(), + attrs: None, + has_default: false, + default_name: None, + has_ignore_comment: false, + } + } + + #[test] + fn two_named_from_same_source_merge() { + let cs = vec![c("x"), c("x")]; + let buckets = compute_buckets(&cs, &[false, false], &[true, true]); + assert_eq!(buckets, vec![MergeBucket::Merged(vec![0, 1])]); + } + + #[test] + fn different_sources_stay_single() { + let cs = vec![c("x"), c("y")]; + let buckets = compute_buckets(&cs, &[false, false], &[true, true]); + assert_eq!(buckets, vec![MergeBucket::Single(0), MergeBucket::Single(1)]); + } + + #[test] + fn decl_with_default_excluded_from_mvp() { + let mut a = c("x"); + a.has_default = true; + a.default_name = Some("Foo".into()); + let cs = vec![a, c("x")]; + let buckets = compute_buckets(&cs, &[false, false], &[false, true]); + // MVP excludes default-bearing decls. + assert_eq!(buckets, vec![MergeBucket::Single(0), MergeBucket::Single(1)]); + } + + #[test] + fn three_in_a_row_merge() { + let cs = vec![c("x"), c("x"), c("x")]; + let buckets = compute_buckets(&cs, &[false, false, false], &[true, true, true]); + assert_eq!(buckets, vec![MergeBucket::Merged(vec![0, 1, 2])]); + } +} From 625f6065de1a5e9892969c4cf9e021fc1be57dd3 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:22:06 +0300 Subject: [PATCH 25/33] feat(config): surface module.importGroups compile diagnostics via resolve_config --- src/configuration/resolve_config.rs | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index dfb17af0..7e4d01a9 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -342,6 +342,18 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) diagnostics.extend(get_unknown_property_diagnostics(config)); + // Surface compile-time diagnostics for module.importGroups. + 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, @@ -488,4 +500,34 @@ mod import_groups_resolution_tests { 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::>()); + } } From 541964671e286b84d9892b69b848812f18470128 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:28:56 +0300 Subject: [PATCH 26/33] test(imports): coverage for unknown/multi-chunk/attributes/.d.ts/knob interactions --- .../import/ImportGroups_Attributes.txt | 15 ++++++++++++ .../import/ImportGroups_DeclarationFile.txt | 22 +++++++++++++++++ .../import/ImportGroups_KnobInteractions.txt | 11 +++++++++ .../import/ImportGroups_MultiChunk.txt | 24 +++++++++++++++++++ .../import/ImportGroups_Unknown.txt | 15 ++++++++++++ .../import/ImportGroups_Unknown_Explicit.txt | 13 ++++++++++ 6 files changed, 100 insertions(+) create mode 100644 tests/specs/declarations/import/ImportGroups_Attributes.txt create mode 100644 tests/specs/declarations/import/ImportGroups_DeclarationFile.txt create mode 100644 tests/specs/declarations/import/ImportGroups_KnobInteractions.txt create mode 100644 tests/specs/declarations/import/ImportGroups_MultiChunk.txt create mode 100644 tests/specs/declarations/import/ImportGroups_Unknown.txt create mode 100644 tests/specs/declarations/import/ImportGroups_Unknown_Explicit.txt 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_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_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_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"; From 1749704beda0e3dd57eb2fcf6144e67a09ebb46c Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:30:56 +0300 Subject: [PATCH 27/33] docs(imports): README and JSON schema for module.importGroups (#493) --- README.md | 67 ++++++++++++++++++++++++++++++++++++++++++ deployment/schema.json | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/README.md b/README.md index a0e254e6..1a59f8ae 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,70 @@ 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.mergeImports` | bool | `false` | Merge multiple imports from the same source (Biome-style). Currently detection only; emission TBD. | +| `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. +- `module.mergeImports` is currently detection-only; merged emission TBD in a follow-up. +- 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..58b78255 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -1027,6 +1027,57 @@ "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.mergeImports": { + "description": "Merge multiple imports from the same source into one declaration.", + "type": "boolean", + "default": false + }, + "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" }, From a4f9b0af43d8f7907816be3625013081c322800a Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:33:57 +0300 Subject: [PATCH 28/33] chore(imports): silence clippy dead_code warnings, use to_vec --- src/generation/generate.rs | 2 +- src/generation/imports/merge.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 11e98805..7dd237a8 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7633,7 +7633,7 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec), @@ -87,6 +89,7 @@ pub enum MergeBucket { /// Compute merge buckets over a slice of decl metadata, in order. /// MVP: only named-only imports from the same source merge. +#[allow(dead_code)] pub fn compute_buckets(candidates: &[MergeCandidate], has_namespace: &[bool], has_named: &[bool]) -> Vec { let mut buckets: Vec = Vec::new(); let mut current: Vec = Vec::new(); @@ -120,6 +123,7 @@ pub fn compute_buckets(candidates: &[MergeCandidate], has_namespace: &[bool], ha buckets } +#[allow(dead_code)] fn flush_current(buckets: &mut Vec, current: &mut Vec) { match current.len() { 0 => {} From 014d9389c3a7685c4c34921049315c3adef865c6 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 22:55:59 +0300 Subject: [PATCH 29/33] perf(imports): cleanup after code review (precompute, FxHashSet, drop redundant state) --- src/configuration/resolve_config.rs | 7 ++++++ src/generation/generate.rs | 33 +++++++++++++++-------------- src/generation/imports/classify.rs | 17 +++++++-------- src/generation/sorting/mod.rs | 2 +- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index 7e4d01a9..f0e89968 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -354,6 +354,13 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) } } + if resolved_config.module_merge_imports { + diagnostics.push(ConfigurationDiagnostic { + property_name: "module.mergeImports".to_string(), + message: "module.mergeImports is currently not implemented; setting it to true has no effect. Tracked for a future release.".to_string(), + }); + } + return ResolveConfigurationResult { config: resolved_config, diagnostics, diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 7dd237a8..93a290fd 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7355,14 +7355,20 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & 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(); 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); - let blank_line = if let Some(boundaries) = stmt_group.subgroup_boundaries.as_ref() { - boundaries.contains(&i) + let blank_line = if has_subgroup_boundaries { + subgroup_boundary_set.contains(&i) } else { node_helpers::has_separating_blank_line(&last_node, &node, context.program) }; @@ -7376,11 +7382,10 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & 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. These were captured BEFORE the partition - // mutated `nodes`; look them up by ORIGINAL source index. - if let Some(source_index_for) = &stmt_group.source_index_for { - let src_idx = source_index_for[i]; - if let Some(comments) = stmt_group.captured_attached_leading.get(src_idx) { + // 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)); } @@ -7490,9 +7495,6 @@ struct StmtGroup<'a> { /// header / license). These stay pinned to the file start and are emitted /// before the per-node loop. captured_detached_header: Vec<&'a Comment>, - /// `source_index_for[post_reorder_position] = original source index`. - /// Used to look up captured comments during emission. - source_index_for: Option>, } fn node_src_with_quotes<'a>(node: &Node<'a>, context: &Context<'a>) -> String { @@ -7535,7 +7537,6 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec 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], |x, y| x.to_lowercase().cmp(&y.to_lowercase())), + SortOrder::CaseInsensitive => cmp_module_specifiers(&node_srcs[a_orig], &node_srcs[b_orig], crate::generation::sorting::cmp_text_case_insensitive), SortOrder::Maintain => unreachable!(), } }, @@ -7653,14 +7653,15 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec = Vec::with_capacity(g.nodes.len()); + 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.source_index_for = Some(ordered.clone()); - g.captured_attached_leading = captured_attached_leading; + g.captured_attached_leading = reordered_attached; g.captured_detached_header = captured_detached_header; } } diff --git a/src/generation/imports/classify.rs b/src/generation/imports/classify.rs index 6227fd8e..93a1d907 100644 --- a/src/generation/imports/classify.rs +++ b/src/generation/imports/classify.rs @@ -49,15 +49,14 @@ fn base_category( } fn is_index_path(src: &str) -> bool { - if src == "." || src == "./" || src == "./index" { - return true; - } - for ext in [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] { - if src == format!("./index{ext}") { - return true; - } - } - false + matches!( + src, + "." | "./" | "./index" + | "./index.ts" | "./index.tsx" + | "./index.js" | "./index.jsx" + | "./index.mjs" | "./index.cjs" + | "./index.mts" | "./index.cts" + ) } #[cfg(test)] diff --git a/src/generation/sorting/mod.rs b/src/generation/sorting/mod.rs index 3a4e7b36..654f0fac 100644 --- a/src/generation/sorting/mod.rs +++ b/src/generation/sorting/mod.rs @@ -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) From b2e6abfb1411d5b511e1672afa4b059d346d5f84 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 23:00:25 +0300 Subject: [PATCH 30/33] revert(imports): drop module.mergeImports (was never wired, will revisit when actually implemented) --- README.md | 2 - deployment/schema.json | 5 - src/configuration/builder.rs | 7 -- src/configuration/resolve_config.rs | 9 -- src/configuration/types.rs | 2 - src/generation/imports/merge.rs | 181 ---------------------------- src/generation/imports/mod.rs | 1 - 7 files changed, 207 deletions(-) delete mode 100644 src/generation/imports/merge.rs diff --git a/README.md b/README.md index 1a59f8ae..e8026013 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,6 @@ This reorders imports across the import block into the listed groups and inserts |---|---|---|---| | `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.mergeImports` | bool | `false` | Merge multiple imports from the same source (Biome-style). Currently detection only; emission TBD. | | `module.builtinsRuntime` | `"node"` \| `"deno"` \| `"bun"` \| `"none"` | `"node"` | Which runtime's built-in module list classifies as `builtin`. | ### Built-in categories @@ -85,5 +84,4 @@ First-match-wins across the list, so position determines precedence. - Descending sort not supported. - TS `import X = require(...)` not reordered. - Imports inside nested `declare module "..."` bodies are not classified. -- `module.mergeImports` is currently detection-only; merged emission TBD in a follow-up. - 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 58b78255..98d91c95 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -1067,11 +1067,6 @@ "default": "separate", "enum": ["separate", "interleave"] }, - "module.mergeImports": { - "description": "Merge multiple imports from the same source into one declaration.", - "type": "boolean", - "default": false - }, "module.builtinsRuntime": { "description": "Which runtime's built-in modules count as `builtin`.", "type": "string", diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs index 6c12eaed..57b33d1f 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -592,13 +592,6 @@ impl ConfigurationBuilder { self.insert("module.typeImports", value.to_string().into()) } - /// Merge multiple imports from the same source into one declaration. - /// - /// Default: `false` - pub fn module_merge_imports(&mut self, value: bool) -> &mut Self { - self.insert("module.mergeImports", value.into()) - } - /// Which runtime's built-in modules count as `builtin`. /// /// Default: `Node` diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index f0e89968..2ee06883 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -134,7 +134,6 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) ), module_import_groups: parse_import_groups(&mut config, &mut diagnostics), module_type_imports: get_value(&mut config, "module.typeImports", TypeImportsMode::Separate, &mut diagnostics), - module_merge_imports: get_value(&mut config, "module.mergeImports", false, &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), @@ -354,13 +353,6 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) } } - if resolved_config.module_merge_imports { - diagnostics.push(ConfigurationDiagnostic { - property_name: "module.mergeImports".to_string(), - message: "module.mergeImports is currently not implemented; setting it to true has no effect. Tracked for a future release.".to_string(), - }); - } - return ResolveConfigurationResult { config: resolved_config, diagnostics, @@ -480,7 +472,6 @@ mod import_groups_resolution_tests { let r = resolve(serde_json::json!({})); assert!(r.config.module_import_groups.is_empty()); assert!(matches!(r.config.module_type_imports, TypeImportsMode::Separate)); - assert!(!r.config.module_merge_imports); assert!(matches!(r.config.module_builtins_runtime, BuiltinsRuntime::Node)); assert!(r.diagnostics.is_empty()); } diff --git a/src/configuration/types.rs b/src/configuration/types.rs index a32ab3cb..24bef29c 100644 --- a/src/configuration/types.rs +++ b/src/configuration/types.rs @@ -440,8 +440,6 @@ pub struct Configuration { pub module_import_groups: Vec, #[serde(rename = "module.typeImports", default = "default_type_imports_mode")] pub module_type_imports: TypeImportsMode, - #[serde(rename = "module.mergeImports", default)] - pub module_merge_imports: bool, #[serde(rename = "module.builtinsRuntime", default = "default_builtins_runtime")] pub module_builtins_runtime: BuiltinsRuntime, /* ignore comments */ diff --git a/src/generation/imports/merge.rs b/src/generation/imports/merge.rs deleted file mode 100644 index f4122de5..00000000 --- a/src/generation/imports/merge.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Merge pass for `module.mergeImports: true`. - -/// A pure model of merge eligibility used in unit tests. The real entry -/// point operates on `ImportDecl` nodes (added in Task 8.2); this struct lets -/// us unit-test the rules without an AST. -#[derive(Clone, Debug, PartialEq)] -#[allow(dead_code)] -pub struct MergeCandidate { - pub src: String, - /// Canonicalized attribute fingerprint (`None` if no `with { ... }`). - pub attrs: Option, - pub has_default: bool, - pub default_name: Option, - pub has_ignore_comment: bool, -} - -pub fn can_merge(a: &MergeCandidate, b: &MergeCandidate) -> bool { - if a.src != b.src { return false; } - if a.attrs != b.attrs { return false; } - if a.has_ignore_comment || b.has_ignore_comment { return false; } - if a.has_default && b.has_default && a.default_name != b.default_name { - return false; - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cand(src: &str) -> MergeCandidate { - MergeCandidate { - src: src.to_string(), - attrs: None, - has_default: false, - default_name: None, - has_ignore_comment: false, - } - } - - #[test] - fn same_src_no_default_merges() { - assert!(can_merge(&cand("./x"), &cand("./x"))); - } - - #[test] - fn different_src_blocks() { - assert!(!can_merge(&cand("./x"), &cand("./y"))); - } - - #[test] - fn conflicting_defaults_block() { - let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; - let b = MergeCandidate { has_default: true, default_name: Some("Bar".into()), ..cand("x") }; - assert!(!can_merge(&a, &b)); - } - - #[test] - fn same_default_merges() { - let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; - let b = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; - assert!(can_merge(&a, &b)); - } - - #[test] - fn different_attrs_block() { - let mut a = cand("x"); - a.attrs = Some("type=json".into()); - let mut b = cand("x"); - b.attrs = Some("type=css".into()); - assert!(!can_merge(&a, &b)); - } - - #[test] - fn dprint_ignore_blocks() { - let mut a = cand("x"); - a.has_ignore_comment = true; - assert!(!can_merge(&a, &cand("x"))); - } -} - -/// Index-based bucket: either a single decl at index `i` or a merge of multiple decls. -#[derive(Debug, PartialEq)] -#[allow(dead_code)] -pub enum MergeBucket { - Single(usize), - Merged(Vec), -} - -/// Compute merge buckets over a slice of decl metadata, in order. -/// MVP: only named-only imports from the same source merge. -#[allow(dead_code)] -pub fn compute_buckets(candidates: &[MergeCandidate], has_namespace: &[bool], has_named: &[bool]) -> Vec { - let mut buckets: Vec = Vec::new(); - let mut current: Vec = Vec::new(); - for i in 0..candidates.len() { - // MVP: only allow named-only imports from same source to merge. - let mvp_eligible = !candidates[i].has_default - && !has_namespace[i] - && candidates[i].attrs.is_none() - && !candidates[i].has_ignore_comment - && has_named[i]; - - if !mvp_eligible { - flush_current(&mut buckets, &mut current); - buckets.push(MergeBucket::Single(i)); - continue; - } - - if current.is_empty() { - current.push(i); - continue; - } - let last = *current.last().unwrap(); - if can_merge(&candidates[last], &candidates[i]) { - current.push(i); - } else { - flush_current(&mut buckets, &mut current); - current.push(i); - } - } - flush_current(&mut buckets, &mut current); - buckets -} - -#[allow(dead_code)] -fn flush_current(buckets: &mut Vec, current: &mut Vec) { - match current.len() { - 0 => {} - 1 => buckets.push(MergeBucket::Single(current[0])), - _ => buckets.push(MergeBucket::Merged(std::mem::take(current))), - } - current.clear(); -} - -#[cfg(test)] -mod bucket_tests { - use super::*; - - fn c(src: &str) -> MergeCandidate { - MergeCandidate { - src: src.to_string(), - attrs: None, - has_default: false, - default_name: None, - has_ignore_comment: false, - } - } - - #[test] - fn two_named_from_same_source_merge() { - let cs = vec![c("x"), c("x")]; - let buckets = compute_buckets(&cs, &[false, false], &[true, true]); - assert_eq!(buckets, vec![MergeBucket::Merged(vec![0, 1])]); - } - - #[test] - fn different_sources_stay_single() { - let cs = vec![c("x"), c("y")]; - let buckets = compute_buckets(&cs, &[false, false], &[true, true]); - assert_eq!(buckets, vec![MergeBucket::Single(0), MergeBucket::Single(1)]); - } - - #[test] - fn decl_with_default_excluded_from_mvp() { - let mut a = c("x"); - a.has_default = true; - a.default_name = Some("Foo".into()); - let cs = vec![a, c("x")]; - let buckets = compute_buckets(&cs, &[false, false], &[false, true]); - // MVP excludes default-bearing decls. - assert_eq!(buckets, vec![MergeBucket::Single(0), MergeBucket::Single(1)]); - } - - #[test] - fn three_in_a_row_merge() { - let cs = vec![c("x"), c("x"), c("x")]; - let buckets = compute_buckets(&cs, &[false, false, false], &[true, true, true]); - assert_eq!(buckets, vec![MergeBucket::Merged(vec![0, 1, 2])]); - } -} diff --git a/src/generation/imports/mod.rs b/src/generation/imports/mod.rs index 370b0f5e..63a114ad 100644 --- a/src/generation/imports/mod.rs +++ b/src/generation/imports/mod.rs @@ -1,4 +1,3 @@ pub mod classify; pub mod partition; -pub mod merge; pub mod resolved; From 6b07e7cd010505083e50ec092f6763cc897d40e9 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 23:04:17 +0300 Subject: [PATCH 31/33] chore: drop internal planning docs from PR --- .../plans/2026-05-21-import-groups.md | 2543 ----------------- .../specs/2026-05-21-import-groups-design.md | 391 --- 2 files changed, 2934 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-21-import-groups.md delete mode 100644 docs/superpowers/specs/2026-05-21-import-groups-design.md diff --git a/docs/superpowers/plans/2026-05-21-import-groups.md b/docs/superpowers/plans/2026-05-21-import-groups.md deleted file mode 100644 index 89d89530..00000000 --- a/docs/superpowers/plans/2026-05-21-import-groups.md +++ /dev/null @@ -1,2543 +0,0 @@ -# Import Grouping Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add ESLint-`import/order`-style grouping of TypeScript/JavaScript -import declarations to dprint-plugin-typescript, with optional Biome-style -merging and per-runtime builtin classification. - -**Architecture:** Extend the existing `get_stmt_groups` / -`StmtGroupKind::Imports` path in `src/generation/generate.rs`. A new pure -classifier+partitioner runs over each consecutive run of `ImportDecl`s, -producing subgroups. The blank-line predicate is taught to force a blank -between subgroups. Side-effect imports already split runs naturally, so they -remain barriers. Everything is opt-in via a new `module.importGroups` config -key — default empty = byte-identical output to today's release. - -**Tech Stack:** Rust 2024, `deno_ast`/SWC views, `dprint-core` PrintItems, -`globset` (new dep), `phf` (new dep, for compile-time core-module hash set), -the existing dprint spec test harness in `tests/spec_test.rs`. - -**Spec:** `docs/superpowers/specs/2026-05-21-import-groups-design.md`. - ---- - -## File Structure - -| File | Status | Responsibility | -|---|---|---| -| `src/utils/builtins.rs` | new | Node 22 / Bun builtin module lists, `is_builtin(src, runtime)`. | -| `src/utils/mod.rs` | modify | `pub mod builtins;` | -| `src/configuration/types.rs` | modify | New enums `TypeImportsMode`, `BuiltinsRuntime`; new struct `ImportGroup` + `ImportMatcher`; new fields on `Configuration`. | -| `src/configuration/builder.rs` | modify | Builder methods + defaults for the four new keys. | -| `src/configuration/resolve_config.rs` | modify | Parse, validate, compile patterns into resolved import groups; diagnostics. | -| `src/generation/imports/mod.rs` | new | Sub-module root: `pub mod classify; pub mod partition; pub mod merge;` | -| `src/generation/imports/classify.rs` | new | Pure classifier: `(src, is_type, &ResolvedGroups, &Config) → usize`. | -| `src/generation/imports/partition.rs` | new | Stable partition + within-group sort. Returns `(Vec, Vec boundaries)`. | -| `src/generation/imports/merge.rs` | new | Optional merge pass over a classified subgroup. | -| `src/generation/mod.rs` | modify | `pub mod imports;` | -| `src/generation/generate.rs` | modify | Extend `StmtGroup`, call `partition_import_group`, force blank line at subgroup boundary. Header-comment pinning. | -| `tests/specs/declarations/import/ImportGroups_*.txt` | new | Spec-test files. | -| `deployment/schema.json` | modify | Add JSON schema entries for the four new keys (if file exists in repo). | -| `Cargo.toml` | modify | Add `globset` (and `phf` if not in deno_ast transitive). | - ---- - -## Conventions Used Throughout - -- All new code lives in modules small enough to hold in head; each new file - has one clear responsibility. -- TDD: every behavior change starts with a failing test (Rust unit test or - dprint spec test) before the implementation step. -- Commit after every passing test step. Conventional Commits prefixes: - `feat:`, `test:`, `refactor:`, `docs:`. Issue tag `(#493)` in subject of - the last user-visible commit per phase. -- Branch already exists: `feat-import-groups`. Run `git switch - feat-import-groups` before starting. -- `cargo test --test specs` runs the dprint spec suite; `cargo test --lib` - runs Rust unit tests. - ---- - -## Phase 0: Branch & Baseline - -### Task 0.1: Switch to branch, run baseline tests green - -**Files:** none. - -- [ ] **Step 1: Switch branch** - -```bash -cd /Users/todor.andonov/projects/oss/dprint-plugin-typescript -git switch feat-import-groups -``` - -- [ ] **Step 2: Run full test suite to confirm green baseline** - -```bash -cargo test --release -``` - -Expected: all tests pass. If not, stop and investigate — do not start the -feature on a red baseline. - ---- - -## Phase 1: Configuration types (no behavior change) - -### Task 1.1: Add `TypeImportsMode` and `BuiltinsRuntime` enums - -**Files:** -- Modify: `src/configuration/types.rs` - -- [ ] **Step 1: Add a failing unit test for round-trip string conversion** - -Append to `src/configuration/types.rs`: - -```rust -#[cfg(test)] -mod import_group_enum_tests { - use super::*; - - #[test] - fn type_imports_mode_round_trip() { - assert_eq!(TypeImportsMode::from_str("separate"), Ok(TypeImportsMode::Separate)); - assert_eq!(TypeImportsMode::from_str("interleave"), Ok(TypeImportsMode::Interleave)); - assert_eq!(TypeImportsMode::Separate.to_string(), "separate"); - } - - #[test] - fn builtins_runtime_round_trip() { - assert_eq!(BuiltinsRuntime::from_str("node"), Ok(BuiltinsRuntime::Node)); - assert_eq!(BuiltinsRuntime::from_str("deno"), Ok(BuiltinsRuntime::Deno)); - assert_eq!(BuiltinsRuntime::from_str("bun"), Ok(BuiltinsRuntime::Bun)); - assert_eq!(BuiltinsRuntime::from_str("none"), Ok(BuiltinsRuntime::None)); - assert_eq!(BuiltinsRuntime::Node.to_string(), "node"); - } -} -``` - -- [ ] **Step 2: Run the test, confirm it fails** - -```bash -cargo test --lib import_group_enum_tests -``` - -Expected: compile error — `TypeImportsMode` / `BuiltinsRuntime` undefined. - -- [ ] **Step 3: Add the enums and `generate_str_to_from!` invocations** - -Insert before the `Configuration` struct (around line 309) in -`src/configuration/types.rs`: - -```rust -/// 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"] -]; -``` - -- [ ] **Step 4: Run the test, confirm it passes** - -```bash -cargo test --lib import_group_enum_tests -``` - -Expected: 2 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/configuration/types.rs -git commit -m "feat(config): add TypeImportsMode and BuiltinsRuntime enums" -``` - -### Task 1.2: Add `ImportGroup` / `ImportMatcher` value types - -**Files:** -- Modify: `src/configuration/types.rs` - -- [ ] **Step 1: Add a failing unit test** - -Append to the same `#[cfg(test)] mod import_group_enum_tests` block: - -```rust -#[test] -fn import_matcher_variants() { - let _c = ImportMatcher::Category(BuiltinCategory::External); - let _p = ImportMatcher::Pattern("foo/*".to_string()); -} - -#[test] -fn builtin_category_round_trip() { - assert_eq!(BuiltinCategory::from_str("builtin"), Ok(BuiltinCategory::Builtin)); - assert_eq!(BuiltinCategory::from_str("external"), Ok(BuiltinCategory::External)); - assert_eq!(BuiltinCategory::from_str("parent"), Ok(BuiltinCategory::Parent)); - assert_eq!(BuiltinCategory::from_str("sibling"), Ok(BuiltinCategory::Sibling)); - assert_eq!(BuiltinCategory::from_str("index"), Ok(BuiltinCategory::Index)); - assert_eq!(BuiltinCategory::from_str("type"), Ok(BuiltinCategory::Type)); - assert_eq!(BuiltinCategory::from_str("unknown"), Ok(BuiltinCategory::Unknown)); -} -``` - -- [ ] **Step 2: Confirm test fails** - -```bash -cargo test --lib import_group_enum_tests -``` - -Expected: compile error — types undefined. - -- [ ] **Step 3: Add `BuiltinCategory`, `ImportMatcher`, `ImportGroup`** - -In `src/configuration/types.rs`, after the `BuiltinsRuntime` block: - -```rust -/// Built-in category strings allowed in `module.importGroups[].match`. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, 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, Debug)] -pub enum ImportMatcher { - Category(BuiltinCategory), - /// Raw glob pattern string. Compiled lazily by resolve_config into a globset. - Pattern(String), -} - -/// One resolved import group, in user-listed order. -#[derive(Clone, Debug)] -pub struct ImportGroup { - pub matchers: Vec, -} -``` - -- [ ] **Step 4: Test passes** - -```bash -cargo test --lib import_group_enum_tests -``` - -Expected: all 4 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/configuration/types.rs -git commit -m "feat(config): add ImportGroup, ImportMatcher, BuiltinCategory" -``` - -### Task 1.3: Add config fields to `Configuration` - -**Files:** -- Modify: `src/configuration/types.rs` - -- [ ] **Step 1: Add fields** - -Find the `/* sorting */` block (around line 344) in `Configuration`. Insert -after `export_declaration_sort_type_only_exports: NamedTypeImportsExportsOrder,`: - -```rust - #[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.mergeImports", default)] - pub module_merge_imports: bool, - #[serde(rename = "module.builtinsRuntime", default = "default_builtins_runtime")] - pub module_builtins_runtime: BuiltinsRuntime, -``` - -At the end of the file (before any `#[cfg(test)]` blocks), add: - -```rust -fn default_type_imports_mode() -> TypeImportsMode { - TypeImportsMode::Separate -} - -fn default_builtins_runtime() -> BuiltinsRuntime { - BuiltinsRuntime::Node -} -``` - -- [ ] **Step 2: Manually add `Serialize` / `Deserialize` derive on `ImportGroup` and `ImportMatcher`** - -Replace: - -```rust -#[derive(Clone, Debug)] -pub enum ImportMatcher { ... } - -#[derive(Clone, Debug)] -pub struct ImportGroup { ... } -``` - -with: - -```rust -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ImportMatcher { - Category(BuiltinCategory), - Pattern { pattern: String }, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ImportGroup { - /// Either a single matcher or a list (list = merged into one group). - #[serde(rename = "match")] - pub matchers: ImportGroupMatch, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ImportGroupMatch { - Single(ImportMatcher), - Multiple(Vec), -} -``` - -Update the earlier unit test that used `ImportMatcher::Pattern("foo/*".to_string())` to: - -```rust -let _p = ImportMatcher::Pattern { pattern: "foo/*".to_string() }; -``` - -- [ ] **Step 3: Run cargo check** - -```bash -cargo check --lib -``` - -Expected: compiles. Resolve any errors before continuing. - -- [ ] **Step 4: Run lib tests** - -```bash -cargo test --lib -``` - -Expected: all existing tests still pass + the new ones from 1.1/1.2. - -- [ ] **Step 5: Commit** - -```bash -git add src/configuration/types.rs -git commit -m "feat(config): add module.importGroups/typeImports/mergeImports/builtinsRuntime fields" -``` - -### Task 1.4: Builder methods - -**Files:** -- Modify: `src/configuration/builder.rs` - -- [ ] **Step 1: Find existing builder defaults block** - -Look at lines 65–75 of `src/configuration/builder.rs` — there's a default -chain calling `.module_sort_import_declarations(SortOrder::Maintain)`. Add -the four new defaults right after it. - -- [ ] **Step 2: Append four builder methods** - -In `src/configuration/builder.rs`, after -`pub fn export_declaration_sort_type_only_exports(...)` (around line 577), -insert: - -```rust - /// Ordered groups for `module.importGroups`. Empty = feature disabled. - /// - /// Default: `[]` - pub fn module_import_groups(&mut self, value: Vec) -> &mut Self { - self.insert("module.importGroups", serde_json::to_value(value).unwrap().into()) - } - - /// 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()) - } - - /// Merge multiple imports from the same source into one declaration. - /// - /// Default: `false` - pub fn module_merge_imports(&mut self, value: bool) -> &mut Self { - self.insert("module.mergeImports", value.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()) - } -``` - -Also add `BuiltinsRuntime, TypeImportsMode` to the `use` imports at the top -of the file. - -- [ ] **Step 3: cargo check** - -```bash -cargo check --lib -``` - -Expected: compiles. - -- [ ] **Step 4: Commit** - -```bash -git add src/configuration/builder.rs -git commit -m "feat(config): add builder methods for import grouping config" -``` - -### Task 1.5: Resolve config (parse + diagnostics) - -**Files:** -- Modify: `src/configuration/resolve_config.rs` - -- [ ] **Step 1: Add `get_value` calls for the three scalar keys** - -Find the `/* sorting */` block in `resolve_config` (around line 118). After -the `export_declaration_sort_type_only_exports` block (which spans roughly -129–134), insert: - -```rust - module_import_groups: parse_import_groups(&mut config, &mut diagnostics), - module_type_imports: get_value(&mut config, "module.typeImports", TypeImportsMode::Separate, &mut diagnostics), - module_merge_imports: get_value(&mut config, "module.mergeImports", false, &mut diagnostics), - module_builtins_runtime: get_value(&mut config, "module.builtinsRuntime", BuiltinsRuntime::Node, &mut diagnostics), -``` - -- [ ] **Step 2: Add `parse_import_groups` helper at the bottom of the file** - -At the end of `src/configuration/resolve_config.rs`: - -```rust -fn parse_import_groups( - config: &mut ConfigKeyMap, - diagnostics: &mut Vec, -) -> Vec { - let Some(raw) = config.shift_remove("module.importGroups") else { - return Vec::new(); - }; - match serde_json::from_value::>(raw.clone().into()) { - Ok(groups) => groups, - Err(err) => { - diagnostics.push(ConfigurationDiagnostic { - property_name: "module.importGroups".to_string(), - message: format!("Invalid import groups configuration: {err}"), - }); - Vec::new() - } - } -} -``` - -Add `use crate::configuration::{ImportGroup, TypeImportsMode, BuiltinsRuntime};` to the imports at top if not already present. - -- [ ] **Step 3: cargo check** - -```bash -cargo check --lib -``` - -Expected: compiles. - -- [ ] **Step 4: Add a config-resolution unit test** - -Append to `src/configuration/resolve_config.rs`: - -```rust -#[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_eq!(r.config.module_type_imports, TypeImportsMode::Separate); - assert!(!r.config.module_merge_imports); - assert_eq!(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()); - 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"); - } -} -``` - -- [ ] **Step 5: Run tests** - -```bash -cargo test --lib import_groups_resolution_tests -``` - -Expected: 3 tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/configuration/resolve_config.rs -git commit -m "feat(config): resolve module.importGroups and related keys" -``` - -### Task 1.6: Regression check — feature off must be byte-identical - -**Files:** none. - -- [ ] **Step 1: Run full spec suite** - -```bash -cargo test --test specs -``` - -Expected: every existing spec still passes. (Feature is opt-in via empty -`module.importGroups`; default is empty; nothing in generation has changed.) - -- [ ] **Step 2: Tag the byte-identical baseline** - -```bash -git tag baseline-pre-import-groups -``` - -Used later to compare against feature-off output. - ---- - -## Phase 2: Builtins utility - -### Task 2.1: Add `phf` and `globset` deps - -**Files:** -- Modify: `Cargo.toml` - -- [ ] **Step 1: Add deps** - -Edit `Cargo.toml` `[dependencies]` to add: - -```toml -globset = "0.4" -phf = { version = "0.11", features = ["macros"] } -``` - -- [ ] **Step 2: cargo check** - -```bash -cargo check --lib -``` - -Expected: deps resolve and compile. - -- [ ] **Step 3: Commit** - -```bash -git add Cargo.toml Cargo.lock -git commit -m "build: add globset and phf dependencies" -``` - -### Task 2.2: Implement `builtins.rs` with Node core list - -**Files:** -- Create: `src/utils/builtins.rs` -- Modify: `src/utils/mod.rs` - -- [ ] **Step 1: Add to module root** - -In `src/utils/mod.rs`, append: - -```rust -pub mod builtins; -``` - -- [ ] **Step 2: Create `src/utils/builtins.rs` with failing tests** - -```rust -//! 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)); - } -} -``` - -- [ ] **Step 3: Run unit tests** - -```bash -cargo test --lib utils::builtins -``` - -Expected: 5 tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add src/utils/mod.rs src/utils/builtins.rs -git commit -m "feat: add is_builtin classifier with Node/Deno/Bun runtimes" -``` - ---- - -## Phase 3: Classifier (pure) - -### Task 3.1: Skeleton module - -**Files:** -- Create: `src/generation/imports/mod.rs` -- Modify: `src/generation/mod.rs` - -- [ ] **Step 1: Create sub-module** - -`src/generation/imports/mod.rs`: - -```rust -pub mod classify; -pub mod partition; -pub mod merge; -pub mod resolved; -``` - -- [ ] **Step 2: Register in parent** - -In `src/generation/mod.rs`, append: - -```rust -pub mod imports; -``` - -- [ ] **Step 3: cargo check fails because submodules don't exist** - -Expected. Continue. - -### Task 3.2: Resolved-config compile step (compile patterns, append `unknown`) - -**Files:** -- Create: `src/generation/imports/resolved.rs` - -- [ ] **Step 1: Write failing test** - -Create `src/generation/imports/resolved.rs`: - -```rust -//! Compiled form of `module.importGroups` ready for fast classification. - -use globset::{Glob, GlobSet, GlobSetBuilder}; - -use crate::configuration::{BuiltinCategory, Configuration, ImportGroup, ImportGroupMatch, ImportMatcher}; - -/// One resolved group: a set of categories + a glob set, in user-listed order. -/// `unknown_index` records which resolved group catches unmatched imports. -#[derive(Debug)] -pub struct ResolvedGroup { - pub categories: Vec, - pub globs: GlobSet, - pub has_globs: bool, -} - -#[derive(Debug)] -pub struct ResolvedGroups { - pub groups: Vec, - pub unknown_index: usize, -} - -/// Compile config's `module.importGroups` into resolved form. -/// `diagnostics` is appended to on bad globs or duplicate categories. -pub fn compile(config: &Configuration, diagnostics: &mut Vec) -> Option { - if config.module_import_groups.is_empty() { - return None; - } - - 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 !seen_categories.insert(*c) { - diagnostics.push(format!("Category `{c:?}` listed more than once in module.importGroups; 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!("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::ConfigurationBuilder; - - fn build(json: serde_json::Value) -> Configuration { - let mut b = ConfigurationBuilder::new(); - let map: dprint_core::configuration::ConfigKeyMap = serde_json::from_value(json).unwrap(); - b.global_config(Default::default()); - for (k, v) in map.into_iter() { - b.insert(&k, v.into()); - } - b.build() - } - - #[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); - // First group still got the builtin; second has empty categories. - assert_eq!(r.groups[0].categories, vec![BuiltinCategory::Builtin]); - assert!(r.groups[1].categories.is_empty()); - } -} -``` - -- [ ] **Step 2: Run tests** - -```bash -cargo test --lib generation::imports::resolved -``` - -Expected: 3 tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/generation/imports/mod.rs src/generation/imports/resolved.rs src/generation/mod.rs -git commit -m "feat(imports): compile import groups into resolved form" -``` - -### Task 3.3: Classifier function - -**Files:** -- Create: `src/generation/imports/classify.rs` - -- [ ] **Step 1: Write file with tests + implementation** - -```rust -//! 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 && 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 { - if src == "." || src == "./" || src == "./index" { - return true; - } - for ext in [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] { - let candidate = format!("./index{ext}"); - if src == candidate { - return true; - } - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::configuration::{ConfigurationBuilder, BuiltinsRuntime, TypeImportsMode}; - use crate::generation::imports::resolved::compile; - - fn classify_with(json: serde_json::Value, src: &str, is_type: bool) -> usize { - let cfg = { - let mut b = ConfigurationBuilder::new(); - b.global_config(Default::default()); - let map: dprint_core::configuration::ConfigKeyMap = serde_json::from_value(json).unwrap(); - for (k, v) in map.into_iter() { - b.insert(&k, v.into()); - } - b.build() - }; - 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" }] - }); - // `react` is external, not builtin → falls to implicit unknown (index 1). - 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" } - ] - }); - // Value import of external → 0; type import → 1. - 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/**" } } - ] - }); - // `@app/foo` matches `external` first → 0. - 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, "@app/foo", false), 0); - assert_eq!(classify_with(cfg, "react", false), 1); - } -} -``` - -- [ ] **Step 2: Run tests** - -```bash -cargo test --lib generation::imports::classify -``` - -Expected: 9 tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/generation/imports/classify.rs -git commit -m "feat(imports): pure classifier of import sources into resolved groups" -``` - ---- - -## Phase 4: Partitioner - -### Task 4.1: `partition_import_group` - -**Files:** -- Create: `src/generation/imports/partition.rs` - -- [ ] **Step 1: Write skeleton + tests** - -Because partitioning operates on SWC `Node`s with lifetimes, the tests will -use a thin abstraction. Write: - -```rust -//! 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, -{ - // Bucket by group_index, preserving relative order (stable). - let mut buckets: Vec> = (0..num_groups).map(|_| Vec::new()).collect(); - for &(g, orig) in classified { - buckets[g].push(orig); - } - - // Sort within each bucket using the provided comparator. - for b in buckets.iter_mut() { - b.sort_by(|&a, &b| cmp_within_group(a, b)); - } - - // Flatten + record boundaries. - 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() { - // Originals classified as group 0, 2, 1, 0, 2. - let input = vec![(0, 0), (2, 1), (1, 2), (0, 3), (2, 4)]; - let (ordered, boundaries) = partition_indices(&input, 3, equal); - // Group 0 first (originals 0, 3); then group 1 (2); then group 2 (1, 4). - 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]); - // Group 1 is empty so only two boundary indices (one per non-empty bucket). - assert_eq!(boundaries, vec![0, 1]); - } - - #[test] - fn within_group_sort_applies() { - // Single group of 3 items; sort descending by original index. - 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]); - } -} -``` - -- [ ] **Step 2: Run tests** - -```bash -cargo test --lib generation::imports::partition -``` - -Expected: 3 tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/generation/imports/partition.rs -git commit -m "feat(imports): stable partition of classified imports" -``` - ---- - -## Phase 5: Integration into `gen_statements` - -### Task 5.1: Stub `merge` module - -**Files:** -- Create: `src/generation/imports/merge.rs` - -- [ ] **Step 1: Empty placeholder** - -```rust -//! Merge pass for `module.mergeImports: true`. Implemented in Phase 8. -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/generation/imports/merge.rs -git commit -m "chore(imports): stub merge module" -``` - -### Task 5.2: Extend `StmtGroup` with subgroup boundaries - -**Files:** -- Modify: `src/generation/generate.rs` - -- [ ] **Step 1: Update struct** - -Locate `struct StmtGroup<'a>` (around line 7416). Replace with: - -```rust -struct StmtGroup<'a> { - kind: StmtGroupKind, - nodes: Vec>, - /// Indices into `nodes` marking start of each subgroup. Only Some for - /// import groups when `module.importGroups` is non-empty. - subgroup_boundaries: Option>, -} -``` - -Update the three construction sites in `get_stmt_groups` to include -`subgroup_boundaries: None,`. - -- [ ] **Step 2: cargo check** - -```bash -cargo check --lib -``` - -Expected: compiles. - -- [ ] **Step 3: Commit** - -```bash -git add src/generation/generate.rs -git commit -m "refactor(generate): add subgroup_boundaries to StmtGroup" -``` - -### Task 5.3: Partition imports during `get_stmt_groups` - -**Files:** -- Modify: `src/generation/generate.rs` - -- [ ] **Step 1: Add classification + partition call** - -At the end of `get_stmt_groups`, just before `groups` is returned, insert: - -```rust - // Apply import-group partitioning when enabled. - if let Some(resolved) = context.resolved_import_groups.as_ref() { - for g in groups.iter_mut() { - if g.kind != StmtGroupKind::Imports { - continue; - } - let classified: Vec<(usize, usize)> = g - .nodes - .iter() - .enumerate() - .map(|(i, node)| { - let (src, is_type) = if let Node::ImportDecl(d) = node { - (d.src.value().as_str().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(); - let (ordered, boundaries) = crate::generation::imports::partition::partition_indices( - &classified, - resolved.groups.len(), - |_a, _b| std::cmp::Ordering::Equal, // intra-group sort handled by existing sorter later - ); - // Reorder nodes in place. - let mut new_nodes: Vec = Vec::with_capacity(g.nodes.len()); - for orig in &ordered { - new_nodes.push(g.nodes[*orig]); - } - g.nodes = new_nodes; - g.subgroup_boundaries = Some(boundaries); - } - } -``` - -- [ ] **Step 2: Add `resolved_import_groups` to `Context`** - -In `src/generation/context.rs`, add to the `Context` struct (find the -`struct Context<'a>` definition): - -```rust - pub resolved_import_groups: Option, -``` - -Initialize it from `Context::new` — find the constructor, add a parameter or -compute from config. The simplest path: compute inline at the call site in -`gen_program` (look for `Context::new(...)`) and pass it in. - -**Where to wire it (concrete instructions):** - -1. Find `Context::new` definition in `src/generation/context.rs`. It currently - takes a number of args (`file_path`, `program`, `config`, etc.). Do NOT - add another argument — instead, compute `resolved_import_groups` from - `config` inside `Context::new` itself. Add this line just before the - struct literal `Context { ... }` is returned: - - ```rust - let mut _import_group_diags: Vec = Vec::new(); - let resolved_import_groups = crate::generation::imports::resolved::compile(config, &mut _import_group_diags); - // diagnostics dropped here for now; surfaced via resolve_config in Task 9.3 - ``` - -2. Add `resolved_import_groups,` to the struct literal. - -This avoids touching every `Context::new` caller in this task. - -- [ ] **Step 3: cargo build** - -```bash -cargo build --lib -``` - -Expected: compiles. Tests not run yet — behavior change comes in Task 5.4. - -- [ ] **Step 4: Commit** - -```bash -git add src/generation/generate.rs src/generation/context.rs -git commit -m "feat(imports): partition imports during get_stmt_groups when enabled" -``` - -### Task 5.4: Force blank line at subgroup boundary - -**Files:** -- Modify: `src/generation/generate.rs` - -- [ ] **Step 1: Update blank-line decision inside `gen_members`-for-statements loop** - -Locate the block at the line numbers around 7310–7314 (currently shown -above), where `has_separating_blank_line` decides whether to push a second -NewLine. Replace that conditional with one that also consults the subgroup -boundary: - -```rust - if let Some(last_node) = &last_node { - separator_items.push_signal(Signal::NewLine); - let crosses_subgroup_boundary = stmt_group - .subgroup_boundaries - .as_ref() - .map(|bs| bs.contains(&i)) - .unwrap_or(false); - if crosses_subgroup_boundary - || node_helpers::has_separating_blank_line(last_node, &node, context.program) - { - separator_items.push_signal(Signal::NewLine); - } - generated_line_separators.insert(i, separator_items); - } -``` - -Important: `i` is the *post-reorder* index. `subgroup_boundaries` stores -post-reorder indices, so this is correct. The first boundary (index 0) is -naturally skipped because `last_node` is `None` then. - -- [ ] **Step 2: cargo build** - -```bash -cargo build --lib -``` - -Expected: compiles. - -- [ ] **Step 3: First end-to-end spec test** - -Create `tests/specs/declarations/import/ImportGroups_Basic.txt`: - -``` -~~ lineWidth: 80, module.importGroups: [{"match":"builtin"},{"match":"external"},{"match":["sibling","index"]}], module.sortImportDeclarations: maintain ~~ -== 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"; -``` - -- [ ] **Step 4: Run new spec** - -```bash -cargo test --test specs declarations::import::ImportGroups_Basic -``` - -Expected: passes. If output diff appears, inspect — likely the blank line is -in the wrong place or sorter is reordering further. - -- [ ] **Step 5: Run full spec suite** - -```bash -cargo test --test specs -``` - -Expected: all existing specs still pass. Feature-off byte-identical. - -- [ ] **Step 6: Commit** - -```bash -git add src/generation/generate.rs tests/specs/declarations/import/ImportGroups_Basic.txt -git commit -m "feat(imports): force blank line at subgroup boundary (#493)" -``` - ---- - -## Phase 6: Within-group sort + spec coverage - -### Task 6.1: Wire intra-subgroup sorter - -**Files:** -- Modify: `src/generation/generate.rs` - -- [ ] **Step 1: Replace the `Equal` placeholder in Task 5.3's partition call** - -Find the `|_a, _b| std::cmp::Ordering::Equal` closure. Replace with a real -comparator that: - -- Reads `context.config.module_sort_import_declarations`. -- If `Maintain`: returns `Equal` (preserves source order in each bucket). -- Else: uses `cmp_module_specifiers` (existing helper in - `src/generation/sorting/module_specifiers.rs`) over the two nodes' source - strings, with `str::cmp` for `CaseSensitive` and case-insensitive cmp for - `CaseInsensitive`. - -```rust - use crate::configuration::SortOrder; - use crate::generation::sorting::module_specifiers::cmp_module_specifiers; - - let cmp = move |a_orig: usize, b_orig: usize| -> std::cmp::Ordering { - let sort = context.config.module_sort_import_declarations; - if sort == SortOrder::Maintain { - return a_orig.cmp(&b_orig); - } - let src_a = node_src_with_quotes(&g.nodes[a_orig], context); - let src_b = node_src_with_quotes(&g.nodes[b_orig], context); - match sort { - SortOrder::CaseSensitive => cmp_module_specifiers(&src_a, &src_b, |x, y| x.cmp(y)), - SortOrder::CaseInsensitive => cmp_module_specifiers(&src_a, &src_b, |x, y| x.to_lowercase().cmp(&y.to_lowercase())), - SortOrder::Maintain => unreachable!(), - } - }; -``` - -`node_src_with_quotes` helper (add near `partition_indices` usage): - -```rust -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 the surrounding quotes. - d.src.text_fast(context.program).to_string() - } else { - String::new() - } -} -``` - -NOTE: this duplicates the existing sorter behavior found via -`get_node_sorter_from_order` — when the existing sorter at the end of -`gen_statements` runs, it will reorder *again* if we don't disable it for -import groups when the feature is on. Disable it: in `get_node_sorter` -(around line 7382), short-circuit when `subgroup_boundaries.is_some()`: - -```rust - fn get_node_sorter<'a>( - group_kind: StmtGroupKind, - stmt_group: &StmtGroup<'a>, - context: &Context<'a>, - ) -> Option<...> { - if stmt_group.subgroup_boundaries.is_some() { - return None; // Already sorted by partitioner. - } - match group_kind { ... } - } -``` - -Update the call site of `get_node_sorter` to pass `&stmt_group`. - -- [ ] **Step 2: Build** - -```bash -cargo build --lib -``` - -Expected: compiles. - -- [ ] **Step 3: Spec test for within-group sort** - -Append to `ImportGroups_Basic.txt`: - -``` -== with caseInsensitive sort within each group == -~~ lineWidth: 80, module.importGroups: [{"match":"external"},{"match":["sibling","index"]}], module.sortImportDeclarations: caseInsensitive ~~ -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"; -``` - -(Adjust the spec format if the harness expects a single config header per -file — split into two files if needed.) - -- [ ] **Step 4: Run** - -```bash -cargo test --test specs declarations::import::ImportGroups_Basic -``` - -Expected: passes. - -- [ ] **Step 5: Commit** - -```bash -git add src/generation/generate.rs tests/specs/declarations/import/ImportGroups_Basic.txt -git commit -m "feat(imports): within-subgroup sort honors module.sortImportDeclarations" -``` - -### Task 6.2: Spec coverage for type-only modes - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_TypeImports.txt` - -- [ ] **Step 1: Write spec file with 4 sub-tests** - -``` -~~ lineWidth: 80, module.importGroups: [{"match":"external"},{"match":"type"}], module.sortImportDeclarations: caseInsensitive ~~ -== typeImports separate (default): pulls import type into type 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 + type specifier stays value (no type-only flag on decl) == -import Foo, { type Bar } from "alpha"; -import type { Baz } from "beta"; - -[expect] -import Foo, { type Bar } from "alpha"; - -import type { Baz } from "beta"; -``` - -``` -~~ lineWidth: 80, module.importGroups: [{"match":"external"}], module.typeImports: interleave ~~ -== typeImports interleave: type and value imports mix in the 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"; -``` - -(Split into two files if the spec runner requires a single header.) - -- [ ] **Step 2: Run** - -```bash -cargo test --test specs declarations::import::ImportGroups_TypeImports -``` - -Expected: passes. - -- [ ] **Step 3: Commit** - -```bash -git add tests/specs/declarations/import/ImportGroups_TypeImports.txt -git commit -m "test(imports): coverage for typeImports separate/interleave modes" -``` - -### Task 6.3: Spec coverage for builtinsRuntime - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Node.txt` -- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Deno.txt` -- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_Bun.txt` -- Create: `tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_None.txt` - -- [ ] **Step 1: Write four spec files** - -For each runtime, write one spec where the input contains: - -``` -import fs from "fs"; -import { test } from "bun:test"; -import { x } from "node:path"; -import { y } from "npm:react"; -import { z } from "react"; -``` - -and the expected output groups them per the runtime table: - -| Runtime | `fs` | `bun:test` | `node:path` | `npm:react` | `react` | -|---|---|---|---|---|---| -| node | builtin | unknown* | builtin | unknown* | external | -| deno | external | external | builtin | external | external | -| bun | builtin | builtin | builtin | external | external | -| none | external | external | external | external | external | - -(*Under `node`, `bun:test` and `npm:react` aren't recognized → external. -Under `deno`/`bun`, `npm:react` is just external by virtue of not matching -any builtin rule.) - -Use config: - -``` -module.importGroups: [{"match":"builtin"},{"match":"external"}], module.builtinsRuntime: , module.sortImportDeclarations: caseInsensitive -``` - -- [ ] **Step 2: Run** - -```bash -cargo test --test specs declarations::import::ImportGroups_BuiltinsRuntime -``` - -Expected: 4 spec files pass. - -- [ ] **Step 3: Commit** - -```bash -git add tests/specs/declarations/import/ImportGroups_BuiltinsRuntime_*.txt -git commit -m "test(imports): coverage for module.builtinsRuntime values" -``` - -### Task 6.4: Spec coverage for pattern groups and first-match-wins - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_Patterns.txt` - -- [ ] **Step 1: Write spec** - -``` -~~ lineWidth: 80, module.importGroups: [{"match":"external"},{"match":{"pattern":"@app/**"}},{"match":"parent"}], module.sortImportDeclarations: caseInsensitive ~~ -== pattern group positioned after external == -import { c } from "@app/foo"; -import { a } from "react"; -import { b } from "../shared"; - -[expect] -import { a } from "react"; - -import { c } from "@app/foo"; - -import { b } from "../shared"; - -~~ lineWidth: 80, module.importGroups: [{"match":{"pattern":"@app/**"}},{"match":"external"}], module.sortImportDeclarations: caseInsensitive ~~ -== pattern group positioned before external == -import { c } from "@app/foo"; -import { a } from "react"; - -[expect] -import { c } from "@app/foo"; - -import { a } from "react"; -``` - -- [ ] **Step 2: Run + commit** - -```bash -cargo test --test specs declarations::import::ImportGroups_Patterns -git add tests/specs/declarations/import/ImportGroups_Patterns.txt -git commit -m "test(imports): pattern matchers + first-match-wins ordering" -``` - -### Task 6.5: Spec coverage for side-effect barriers, `// dprint-ignore`, header comments - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_Barriers.txt` - -- [ ] **Step 1: Write tests** - -Each test uses the ESLint mirror config. Cover: - -- Side-effect import in the middle of a run: imports above & below are - grouped independently; the side-effect stays put with no reorder around it. -- `// dprint-ignore` on a single import: it stays in source position, acts - as barrier. -- License header (`/* @license */`) followed by blank line then imports: - header pinned to file start; imports reordered below it. -- `// @ts-check` shebang-style on first line: preserved. - -(Write 4 sub-tests with concrete inputs and expecteds. Use existing test -files for tone/format.) - -- [ ] **Step 2: Run + commit** - -```bash -cargo test --test specs declarations::import::ImportGroups_Barriers -git add tests/specs/declarations/import/ImportGroups_Barriers.txt -git commit -m "test(imports): side-effect barrier, dprint-ignore, header comment cases" -``` - ---- - -## Phase 7: Header-comment pinning - -### Task 7.1: Detect detached file-leading comments and pin them - -**Files:** -- Modify: `src/generation/generate.rs` - -- [ ] **Step 1: Identify how leading comments attach to first import today** - -In `gen_statements` (the loop that processes statements), the first node's -leading comments come via `node.leading_comments_fast(context.program)`. -After reorder, if a non-first import becomes first, its leading comments -travel with it — which is what we want for *attached* comments but not for -*detached* file-header comments. - -- [ ] **Step 2: Build a "header bag"** - -Before the partition step in `get_stmt_groups` (the new code added in -Task 5.3), introduce: - -```rust -fn split_header_comments<'a>( - first_import: &Node<'a>, - context: &Context<'a>, -) -> Vec { - // Take leading comments of the first import. Walk backward from the import - // start, collecting consecutive comments. A comment is "detached" if there - // is a blank line between it and the import (i.e., comment.hi_line + 2 <= - // import.start_line). - let comments = first_import.leading_comments_fast(context.program); - let import_start_line = first_import.start_line_fast(context.program); - let mut detached = Vec::new(); - for c in comments.into_iter() { - let c_end_line = c.end_line_fast(context.program); - if c_end_line + 1 < import_start_line { - detached.push(c.clone()); - } - } - detached -} -``` - -(`end_line_fast` may need adaptation depending on the comment helper API. -Use the existing helpers in `src/generation/comments.rs` for guidance.) - -- [ ] **Step 3: Emit detached comments as the very first items of the import block** - -Inside the partition branch added in Task 5.3, before reordering nodes: - -```rust - let detached = if let Some(first) = g.nodes.first() { - split_header_comments(first, context) - } else { Vec::new() }; - // Stash detached comments in the StmtGroup for emission at index 0. - g.detached_header_comments = detached; -``` - -Add a `detached_header_comments: Vec` field on `StmtGroup` (use -the same comment type as the SWC `comments::Comment`). - -In the emission loop, *before* the first node, emit those detached comments -verbatim and suppress them from the first node's `leading_comments_fast` -result. The suppression can be done by tracking a `HashSet` of -already-emitted comment positions on the `Context` for the duration of the -group emission. - -- [ ] **Step 4: Run header-comment spec test from Task 6.5** - -```bash -cargo test --test specs declarations::import::ImportGroups_Barriers -``` - -Expected: now passes. - -- [ ] **Step 5: Commit** - -```bash -git add src/generation/generate.rs -git commit -m "feat(imports): pin detached file-header comments above first import after reorder" -``` - ---- - -## Phase 8: Merge pass (`module.mergeImports: true`) - -### Task 8.1: Eligibility check - -**Files:** -- Modify: `src/generation/imports/merge.rs` - -- [ ] **Step 1: Write a pure eligibility test** - -```rust -//! Merge pass for `module.mergeImports: true`. - -/// A simplified, pure model of merge eligibility for testing. The real entry -/// point operates on `ImportDecl` nodes; this struct lets us unit-test the -/// rules without an AST. -#[derive(Clone)] -pub struct MergeCandidate { - pub src: String, - pub attrs: Option, // canonicalized attribute fingerprint - pub has_default: bool, - pub default_name: Option, - pub has_ignore_comment: bool, -} - -pub fn can_merge(a: &MergeCandidate, b: &MergeCandidate) -> bool { - if a.src != b.src { return false; } - if a.attrs != b.attrs { return false; } - if a.has_ignore_comment || b.has_ignore_comment { return false; } - if a.has_default && b.has_default && a.default_name != b.default_name { - return false; - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cand(src: &str) -> MergeCandidate { - MergeCandidate { - src: src.to_string(), - attrs: None, - has_default: false, - default_name: None, - has_ignore_comment: false, - } - } - - #[test] - fn same_src_no_default_merges() { - assert!(can_merge(&cand("./x"), &cand("./x"))); - } - - #[test] - fn different_src_blocks() { - assert!(!can_merge(&cand("./x"), &cand("./y"))); - } - - #[test] - fn conflicting_defaults_block() { - let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; - let b = MergeCandidate { has_default: true, default_name: Some("Bar".into()), ..cand("x") }; - assert!(!can_merge(&a, &b)); - } - - #[test] - fn same_default_merges() { - let a = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; - let b = MergeCandidate { has_default: true, default_name: Some("Foo".into()), ..cand("x") }; - assert!(can_merge(&a, &b)); - } - - #[test] - fn different_attrs_block() { - let mut a = cand("x"); - a.attrs = Some("type=json".into()); - let mut b = cand("x"); - b.attrs = Some("type=css".into()); - assert!(!can_merge(&a, &b)); - } - - #[test] - fn dprint_ignore_blocks() { - let mut a = cand("x"); - a.has_ignore_comment = true; - assert!(!can_merge(&a, &cand("x"))); - } -} -``` - -- [ ] **Step 2: Run** - -```bash -cargo test --lib generation::imports::merge -``` - -Expected: 6 tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add src/generation/imports/merge.rs -git commit -m "feat(imports): merge eligibility predicate" -``` - -### Task 8.2: AST-level merge synthesis - -**Files:** -- Modify: `src/generation/imports/merge.rs` -- Modify: `src/generation/generate.rs` - -This is the most involved task because it produces a new `PrintItems` chunk -representing the merged declaration, since dprint can't mutate the AST. - -- [ ] **Step 1: Design the entry point** - -In `merge.rs`, add: - -```rust -use deno_ast::view::*; -use crate::generation::context::Context; -use dprint_core::formatting::PrintItems; - -/// Given a contiguous run of import decls already classified into the same -/// subgroup and sorted by within-group order, return: -/// - a Vec where each bucket is either a single decl (unmerged) -/// or a list of decls to be emitted as one merged declaration. -pub enum MergeBucket<'a> { - Single(&'a ImportDecl<'a>), - Merged(Vec<&'a ImportDecl<'a>>), -} - -pub fn build_buckets<'a>( - decls: &[&'a ImportDecl<'a>], - context: &Context<'a>, -) -> Vec>; - -/// Generate the print items for a merged group of decls. Synthesises a -/// single declaration with the union of specifiers, defaults-first ordering, -/// type markers preserved, and concatenated leading comments. -pub fn gen_merged( - decls: &[&ImportDecl], - context: &mut Context, -) -> PrintItems; -``` - -- [ ] **Step 2: Implement `build_buckets` (pure)** - -Walk the slice; for each pair of adjacent decls, call `can_merge` (after -building a `MergeCandidate` from the `ImportDecl`). Extend the current -bucket if eligible, else start a new one. - -Add a helper: - -```rust -fn candidate_for<'a>(decl: &'a ImportDecl<'a>, context: &Context<'a>) -> MergeCandidate { - // src - let src = decl.src.value().to_string(); - // attrs: canonicalize to sorted key=value pairs - let attrs = decl.with.as_ref().map(|w| { - let mut pairs: Vec<(String, String)> = w.props.iter().filter_map(|p| { - // Only ImportAttribute pairs; if SWC view exposes them differently, adapt. - match p { /* ImportAttribute kv */ _ => None } - }).collect(); - pairs.sort(); - pairs.into_iter().map(|(k, v)| format!("{k}={v}")).collect::>().join(",") - }); - // default specifier - let default_spec = decl.specifiers.iter().find_map(|s| match s { - ImportSpecifier::Default(d) => Some(d.local.sym().to_string()), - _ => None, - }); - // dprint-ignore - let has_ignore = context.has_ignore_comment(&decl.range()); - MergeCandidate { - src, - attrs, - has_default: default_spec.is_some(), - default_name: default_spec, - has_ignore_comment: has_ignore, - } -} -``` - -(`has_ignore_comment` may be a helper on `Context`; see -`src/utils/file_text_has_ignore_comment.rs` for the existing pattern.) - -- [ ] **Step 3: Implement `gen_merged`** - -```rust -pub fn gen_merged( - decls: &[&ImportDecl], - context: &mut Context, -) -> PrintItems { - // Union of specifiers. - let mut default_spec: Option<&ImportDefaultSpecifier> = None; - let mut namespace_spec: Option<&ImportStarAsSpecifier> = None; - let mut named: Vec<(&ImportNamedSpecifier, bool /* is_type */)> = Vec::new(); - - for d in decls { - for s in d.specifiers.iter() { - match s { - ImportSpecifier::Default(x) => { - if default_spec.is_none() { default_spec = Some(x); } - } - ImportSpecifier::Namespace(x) => { - if namespace_spec.is_none() { namespace_spec = Some(x); } - } - ImportSpecifier::Named(x) => { - let is_type = d.type_only() || x.is_type_only(); - named.push((x, is_type)); - } - } - } - } - - // Sort named per importDeclaration.sortNamedImports. - // (Reuse the existing helper that emits a named-imports block, but feed - // it the merged specifier list. The simplest approach is to synthesize a - // string for the merged declaration here and call back into the existing - // generator. Concretely: build a textual ImportDecl source, then reparse - // — but that loses comments. Instead, manually build PrintItems mirroring - // gen_import_decl's structure with our merged specifier list.) - - // For v1 of merge, implement the simple text-emission path: - // - emit leading comments (concatenation from all merged decls). - // - emit `import` keyword. - // - if any merged decl was fully type-only, decide: if ALL decls are - // type-only, emit `import type {...}`; else emit value form and tag - // individual specifiers with `type`. - // - emit default + namespace + named. - // - emit `from ""`. - // - emit attrs if any (must all be equal — eligibility ensured this). - // - emit semicolon per existing config. - - let all_type_only = decls.iter().all(|d| d.type_only()); - - // Reuse the existing helpers from src/generation/generate.rs that emit - // the keyword + specifier list + `from "src"` for an ImportDecl. The - // cleanest mechanism is to pick one of the merged decls (the first) as - // the "host" and call `gen_import_decl(host, context)` after temporarily - // swapping its specifier list with the merged specifier list. - // - // Since the SWC view nodes are immutable, the actual approach is: - // 1. Build a synthetic source string for the merged declaration: - // `import , * as , { a, type B, c } from "";` - // Pick defaults/namespace/named from the union built above. - // 2. Parse it with deno_ast::parse_module (a single decl). - // 3. Generate via gen_import_decl over the parsed synthetic node. - // 4. Prepend concatenated leading comments from the merged decls. - // - // Use synthesize_merged_source(decls) and parse_synthetic_decl(src) as - // helpers to keep gen_merged short. Tests in Task 8.3 verify roundtrip. - let synth_src = synthesize_merged_source(decls, default_spec, namespace_spec, &named, all_type_only); - let synth_decl = parse_synthetic_decl(&synth_src, context); - let mut items = PrintItems::new(); - items.extend(concatenated_leading_comments(decls, context)); - items.extend(crate::generation::generate::gen_import_decl(&synth_decl, context)); - items -} -``` - -The three helpers (`synthesize_merged_source`, `parse_synthetic_decl`, -`concatenated_leading_comments`) are private to `merge.rs`. Sketch: - -```rust -fn synthesize_merged_source( - decls: &[&ImportDecl], - default: Option<&ImportDefaultSpecifier>, - namespace: Option<&ImportStarAsSpecifier>, - named: &[(&ImportNamedSpecifier, bool)], - all_type_only: bool, -) -> String { - let mut out = String::from("import "); - if all_type_only { out.push_str("type "); } - let mut parts: Vec = Vec::new(); - if let Some(d) = default { parts.push(d.local.sym().to_string()); } - if let Some(ns) = namespace { parts.push(format!("* as {}", ns.local.sym())); } - if !named.is_empty() { - let inner: Vec = named.iter().map(|(n, is_type)| { - let prefix = if *is_type && !all_type_only { "type " } else { "" }; - match &n.imported { - Some(orig) => format!("{prefix}{} as {}", orig.sym(), n.local.sym()), - None => format!("{prefix}{}", n.local.sym()), - } - }).collect(); - parts.push(format!("{{ {} }}", inner.join(", "))); - } - out.push_str(&parts.join(", ")); - out.push_str(&format!(" from {:?}", decls[0].src.value())); - // Attributes: eligibility ensured all equal, so take from first. - if let Some(attrs_text) = serialize_attrs(decls[0]) { - out.push_str(&format!(" with {}", attrs_text)); - } - out.push(';'); - out -} - -fn parse_synthetic_decl<'a>(src: &str, context: &Context<'a>) -> ImportDecl<'a> { - // Use deno_ast::parse_module with the same syntax flags as the host - // program. Pull the first item, downcast to ImportDecl. The parsed - // module's lifetime is unrelated to 'a — store it on Context's arena - // (add a Vec field for synthetic merges). - todo!("see comments in this file for the arena trick") -} -``` - -The arena trick: add a `synthetic_arena: Vec` to -`Context` so synthetic decls live long enough. Push the parsed module into -the arena, return a reference to the parsed `ImportDecl`. - -**Acknowledged complexity:** this task is intentionally larger than the -2–5 minute target. Budget ~half a day. If the engineer hits trouble with -the arena/lifetime dance, consider falling back to hand-building -`PrintItems` directly using helpers from `gen_import_decl` for the named -specifier block — but the synthetic-parse path keeps comment and attribute -handling consistent with the rest of dprint-plugin-typescript. - -- [ ] **Step 4: Wire `build_buckets` into the partition emission** - -In `gen_statements` (the loop), when iterating an Imports group with -`subgroup_boundaries.is_some()` and `context.config.module_merge_imports`, -walk each subgroup's slice through `build_buckets`. For each: -- `Single(d)`: emit via existing `gen_node(*d, context)`. -- `Merged(ds)`: emit via `gen_merged(ds, context)`. - -- [ ] **Step 5: Add spec for basic merge** - -`tests/specs/declarations/import/ImportGroups_Merge_Basic.txt`: - -``` -~~ lineWidth: 80, module.importGroups: [{"match":"external"}], module.sortImportDeclarations: maintain, module.mergeImports: true ~~ -== merges two imports from same source == -import { a } from "x"; -import { b } from "x"; - -[expect] -import { a, b } from "x"; -``` - -- [ ] **Step 6: Run** - -```bash -cargo test --test specs declarations::import::ImportGroups_Merge -``` - -Expected: passes for the basic case. - -- [ ] **Step 7: Commit** - -```bash -git add src/generation/imports/merge.rs src/generation/generate.rs tests/specs/declarations/import/ImportGroups_Merge_Basic.txt -git commit -m "feat(imports): merge multiple imports from same source when enabled" -``` - -### Task 8.3: Merge edge-case specs - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_Merge_*.txt` (six files) - -- [ ] **Step 1: Write specs for** - -1. Side-effect + named → merged to named: `import "./x"; import { a } from "./x";` → `import { a } from "./x";`. -2. Default + namespace → `import x, * as y from "z"`. -3. Value + type-only → `import { a, type B } from "x"`. -4. All type-only → `import type { A, B } from "x"`. -5. Conflicting defaults → both kept, diagnostic in `r.diagnostics`. -6. Different `with { ... }` attrs → both kept. - -- [ ] **Step 2: Run + commit** - -```bash -cargo test --test specs declarations::import::ImportGroups_Merge -git add tests/specs/declarations/import/ImportGroups_Merge_*.txt -git commit -m "test(imports): merge edge cases (side-effect, type, conflicts, attrs)" -``` - ---- - -## Phase 9: Diagnostics & invalid configs - -### Task 9.1: Unknown category string diagnostic - -**Files:** -- Modify: `src/configuration/resolve_config.rs` - -- [ ] **Step 1: Add a test** - -In `import_groups_resolution_tests`: - -```rust -#[test] -fn unknown_category_string_diagnostic() { - let r = resolve(serde_json::json!({ - "module.importGroups": [{ "match": "buildin" }] - })); - assert!(!r.diagnostics.is_empty()); - assert!(r.diagnostics[0].message.contains("buildin")); -} -``` - -- [ ] **Step 2: Update `parse_import_groups` to validate string categories** - -After serde deserialization succeeds, walk each `ImportMatcher::Category` -value and verify it's a known variant. Since the enum is closed at the -serde level, an unknown variant currently errors out at deserialization — -test it surfaces the variant name in the error message. - -Alternatively: relax the enum to `Category(String)` for parsing, and -validate in `compile`. - -- [ ] **Step 3: Verify the test passes** - -```bash -cargo test --lib import_groups_resolution_tests -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/configuration/resolve_config.rs -git commit -m "feat(config): diagnostic for unknown category strings in module.importGroups" -``` - -### Task 9.2: `type` listed under `typeImports: interleave` diagnostic - -**Files:** -- Modify: `src/generation/imports/resolved.rs` - -- [ ] **Step 1: Add test** - -```rust -#[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"))); -} -``` - -- [ ] **Step 2: Implement** - -In `compile`, when iterating matchers, if `c == BuiltinCategory::Type` and -`config.module_type_imports == TypeImportsMode::Interleave`, push a diagnostic -and skip. - -- [ ] **Step 3: Run + commit** - -```bash -cargo test --lib generation::imports::resolved -git add src/generation/imports/resolved.rs -git commit -m "feat(imports): diagnostic for \"type\" group under typeImports=interleave" -``` - -### Task 9.3: Bubble compile diagnostics into resolve_config diagnostics list - -**Files:** -- Modify: `src/configuration/resolve_config.rs` -- Modify: `src/generation/generate.rs` - -- [ ] **Step 1: Move compile out of `gen_program`** - -Move the call to `compile(...)` from `gen_program` (added in Task 5.3) into -`resolve_config` so diagnostics surface through the normal mechanism. Cache -the result on the resolved config (e.g. via a new field -`resolved_import_groups: Option`). - -Adjust `Configuration` to either hold the resolved groups or to be paired -with a `ResolvedConfiguration` wrapper. The simplest path: - -- Keep `module_import_groups: Vec` on `Configuration` (the - serialized form). -- Add a non-serialized `#[serde(skip)] pub resolved_import_groups: Option` on `Configuration`. -- Populate during `resolve_config`. -- Generation reads from `context.config.resolved_import_groups`. - -- [ ] **Step 2: Wire diagnostics** - -In `parse_import_groups` (or a new function called immediately after), call -`compile` and append any returned diagnostic strings to the -`diagnostics: &mut Vec` with property name -`"module.importGroups"`. - -- [ ] **Step 3: Run all unit + spec tests** - -```bash -cargo test -``` - -Expected: green. - -- [ ] **Step 4: Commit** - -```bash -git add src/configuration/resolve_config.rs src/generation/generate.rs -git commit -m "refactor(config): compile import groups during resolve, bubble diagnostics" -``` - ---- - -## Phase 10: Remaining spec coverage - -### Task 10.1: Catch-all and unknown position - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_Unknown.txt` - -- [ ] **Step 1: Tests** - -- Implicit catch-all at end (when no `unknown` listed): unmatched import - appears in a final group. -- Explicit `unknown` placement: place `{ "match": "unknown" }` at the - beginning; unmatched imports appear first. - -- [ ] **Step 2: Run + commit** - -```bash -cargo test --test specs declarations::import::ImportGroups_Unknown -git add tests/specs/declarations/import/ImportGroups_Unknown.txt -git commit -m "test(imports): implicit and explicit unknown group placement" -``` - -### Task 10.2: Multi-chunk + non-import barrier - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_MultiChunk.txt` - -Cover: imports, then non-import statement, then more imports. Each chunk -grouped independently; no cross-chunk reorder. - -- [ ] Run + commit. - -### Task 10.3: Import attributes - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_Attributes.txt` - -Cover: `import x from "y" with { type: "json" }`. Classified by path, -attribute preserved. With `mergeImports: true`, two such imports with -different attribute values don't merge. - -- [ ] Run + commit. - -### Task 10.4: `.d.ts` and `declare module` - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_DeclarationFiles.txt` - -Cover: `.d.ts` file with imports → grouped; imports inside `declare module -"foo" { ... }` → untouched. - -- [ ] Run + commit. - -### Task 10.5: Interaction with existing knobs - -**Files:** -- Create: `tests/specs/declarations/import/ImportGroups_KnobInteractions.txt` - -Cover: -- `importDeclaration.forceSingleLine: true` with grouping: declarations - still single-lined per knob. -- `importDeclaration.sortNamedImports: caseInsensitive` with grouping: each - decl's specifier list still sorted. -- `module.sortImportDeclarations: maintain` with grouping: within-group - order preserved; only cross-group reorder happens. - -- [ ] Run + commit. - -### Task 10.6: Idempotence guarantee - -**Files:** none. - -- [ ] **Step 1: Confirm `format_twice: true` in spec_test.rs** - -`tests/spec_test.rs` already passes `format_twice: true`, so all specs -already verify idempotence. If any spec breaks under second-pass formatting, -treat it as a feature bug: classification must produce the same output on -already-formatted input. - -```bash -cargo test --test specs -``` - -Expected: all pass under double-format. - ---- - -## Phase 11: Documentation - -### Task 11.1: README + JSON schema - -**Files:** -- Modify: `README.md` -- Modify: `deployment/schema.json` (if it exists) - -- [ ] **Step 1: README** - -Add a section "Import grouping" with the four config keys, a basic example, -and a migration table from ESLint `import/order`. Keep concise — link to the -design doc for the full spec. - -- [ ] **Step 2: schema.json** - -If `deployment/schema.json` exists, add entries for: -- `module.importGroups` — array of objects with `match`. -- `module.typeImports` — `"separate" | "interleave"`. -- `module.mergeImports` — boolean. -- `module.builtinsRuntime` — `"node" | "deno" | "bun" | "none"`. - -- [ ] **Step 3: Commit** - -```bash -git add README.md deployment/schema.json -git commit -m "docs(imports): README and JSON schema for module.importGroups (#493)" -``` - ---- - -## Phase 12: Final verification - -### Task 12.1: Full test run - -- [ ] **Step 1: Run everything** - -```bash -cargo test --release -``` - -Expected: all unit + spec tests pass. - -- [ ] **Step 2: Compare against baseline-pre-import-groups for feature-off identity** - -```bash -git diff baseline-pre-import-groups -- tests/specs/ # any unintended changes? -``` - -Expected: only new spec files added under `tests/specs/declarations/import/ImportGroups_*.txt`; no existing specs modified. - -- [ ] **Step 3: Clippy clean** - -```bash -cargo clippy --all-targets -- -D warnings -``` - -Expected: no warnings. - -- [ ] **Step 4: Format the repo with itself** - -```bash -cargo run -- fmt -``` - -(Or whatever dprint self-format invocation the repo uses — check `.github/` -or `scripts/`.) - -- [ ] **Step 5: Push branch + open draft PR** - -```bash -git push -u origin feat-import-groups -gh pr create --draft --title "feat: import grouping (#493)" --body "Closes #493. See docs/superpowers/specs/2026-05-21-import-groups-design.md." -``` - ---- - -## Out-of-scope reminders (do not implement here) - -- CJS `require(...)`, dynamic `import()`, TS `import = require()`. -- Module resolver / tsconfig paths. -- Natural sort, descending sort. -- Imports inside `declare module "..."` bodies (skipped by design). -- Webpack-style alias resolution beyond raw glob. - -If any of these come up during implementation, file an issue and move on. diff --git a/docs/superpowers/specs/2026-05-21-import-groups-design.md b/docs/superpowers/specs/2026-05-21-import-groups-design.md deleted file mode 100644 index 46c5e8e6..00000000 --- a/docs/superpowers/specs/2026-05-21-import-groups-design.md +++ /dev/null @@ -1,391 +0,0 @@ -# Import Grouping (issue #493) - -Date: 2026-05-21 -Tracking: https://github.com/dprint/dprint-plugin-typescript/issues/493 - -## Goal - -Add ESLint-`import/order`-style import grouping to dprint-plugin-typescript. -dprint will classify every ES import declaration, reorder them across the -import block to match a user-declared group order, and insert exactly one -blank line between groups. Eliminates the need for `eslint-plugin-import`'s -`order` rule for users on dprint. - -## Non-goals - -- CommonJS `require(...)` ordering. -- Dynamic `import()` expressions. -- Webpack/TS-resolver-based classification (no module resolution performed). -- `eslint-plugin-import` options without a clean dprint analog: - `warnOnUnassignedImports`, `consolidateIslands`, `pathGroupsExcludedImportTypes`, - descending alphabetize, `newlines-between: "never" | "ignore" | "always-and-inside-groups"`. -- TypeScript `import X = require(...)` / `export X = ...`. -- Re-exports `export ... from "..."` (handled by the existing `Exports` group; - not regrouped by this feature). -- **Natural sort** of import sources. Existing `SortOrder` (lexicographic, - case-sensitive/-insensitive) only. -- Classifying imports inside nested `declare module "..."` bodies. Only the - top-level statement list of a program is partitioned. - -## Configuration - -```jsonc -{ - // Ordered list of groups. Empty/absent = feature off; existing behavior preserved. - "module.importGroups": [ - { "match": "builtin" }, - { "match": "external" }, - { "match": "parent" }, - { "match": ["sibling", "index"] } - ], - - // How type-only imports are classified. - // "separate" (default): a distinct implicit category "type"; user places it in the list. - // "interleave" : classified by source path the same as value imports. - "module.typeImports": "separate", - - // Merge multiple imports from the same source into one declaration. - // false (default) : leave as written (matches ESLint import/order). - // true : merge compatible duplicates (matches Biome organizeImports). - "module.mergeImports": false, - - // What counts as a runtime builtin. - // "node" (default): `node:*` prefix or Node core list (e.g. `fs`, `path`). - // "deno" : `node:*` prefix only. `npm:`, `jsr:`, `https://` → external. - // "bun" : `node:*`, `bun:*`, and Node core list. - // "none" : nothing matches `builtin`; use pattern groups instead. - "module.builtinsRuntime": "node" -} -``` - -### `match` value forms - -- String: one of `"builtin" | "external" | "parent" | "sibling" | "index" | "type" | "unknown"`. -- Array: union of strings and/or pattern objects, merged into one group (no blank line between). -- Pattern object: `{ "pattern": "" }` — matched against the import source literal (no resolution). -- Arrays may mix: `["external", { "pattern": "@app/**" }]`. - -### Built-in categories - -| Category | Match condition | -|-------------|---------------------------------------------------------------------------------| -| `builtin` | depends on `module.builtinsRuntime` — see runtime table below | -| `external` | bare specifier not matched as builtin (e.g. `react`, `@scope/pkg`); also `npm:*`, `jsr:*`, `https://*` URL imports under any runtime | -| `parent` | source starts with `../` | -| `sibling` | source starts with `./` and is not an index path | -| `index` | source is `.`, `./`, `./index`, or `./index.{ts,tsx,js,jsx,mjs,cjs,mts,cts}` | -| `type` | `import type` / `export type` declaration, only when `typeImports: "separate"` | -| `unknown` | implicit catch-all; placed at end if not listed; user may insert explicitly | - -### Builtin runtime table - -| `module.builtinsRuntime` | Matches as `builtin` | -|--------------------------|--------------------------------------------------------------------------| -| `"node"` (default) | `node:*` prefix OR source in shipped Node core list | -| `"deno"` | `node:*` prefix only | -| `"bun"` | `node:*` prefix, `bun:*` prefix, OR source in shipped Node core list | -| `"none"` | nothing | - -Shipped lists are hardcoded snapshots — Node core from the Node 22 LTS -`module.builtinModules`, Bun core from Bun's documented `bun:*` modules. -Snapshot version recorded in the source file header for future bumps. - -### Resolution & precedence - -- Parsed once in `resolve_config` into `Vec` (`ResolvedGroup` = - `Vec`, `Matcher` = `Category(BuiltinCategory) | Pattern(GlobPattern)`). -- Append implicit `unknown` group if the user did not list one. -- First-match-wins across the resolved list (positional precedence replaces - ESLint `pathGroupsExcludedImportTypes`). -- Diagnostic (warning, first occurrence wins) when the same category appears twice. -- Glob via `globset` crate. - -### Defaults & opt-in - -- Default `module.importGroups` is empty → feature off → byte-identical output - vs. the previous version on the full existing spec suite. -- Default `module.typeImports` is `"separate"` but only takes effect when the - feature is enabled. - -## Approach (locked) - -Approach **A** — subgrouping inside the existing `StmtGroup::Imports` run. - -Rationale: - -- `get_stmt_groups` in `src/generation/generate.rs` already groups consecutive - import declarations into a single `StmtGroup` and excludes side-effect - imports (`!decl.specifiers.is_empty()` filter), so side-effect imports - already act as positional barriers. -- Adding a classify+partition step inside that branch reuses all existing - blank-line, comment, and sort machinery. -- No new global passes; no synthetic-node mechanism needed. - -## Algorithm - -```text -classify(import_decl, config) -> usize // index into resolved groups - src = import_decl.src.value - is_type = import_decl.type_only - || (typeImports == "separate" && every specifier is `type`) - - category = - if is_type && typeImports == "separate": "type" - elif is_builtin(src, config.builtinsRuntime): "builtin" - elif src.starts_with("../"): "parent" - elif src is "." || "./" || "./index" - || matches "./index.{ts,tsx,js,jsx,mjs,cjs,mts,cts}": - "index" - elif src.starts_with("./"): "sibling" - else: "external" - -is_builtin(src, runtime): - match runtime: - "node": src.starts_with("node:") || NODE_CORE_LIST.contains(src) - "deno": src.starts_with("node:") - "bun" : src.starts_with("node:") || src.starts_with("bun:") || NODE_CORE_LIST.contains(src) - "none": false - - // Walk resolved group list in order; return first index where any matcher - // is `Category(category)` or `Pattern(glob)` matching src. - // If none match: index of `unknown` group (implicit end if absent). -``` - -Within each non-empty partition apply -`get_node_sorter_from_order(module_sort_import_declarations, NamedTypeImportsExportsOrder::None)` -(existing helper). - -### Merge pass (when `module.mergeImports = true`) - -Runs once per subgroup, after the within-group sort. Walks consecutive -declarations; merges runs sharing the same `(source, attributes)` key. - -**Merge eligibility** — two adjacent decls `A` and `B` may merge iff all hold: - -- `A.src.value == B.src.value` (string equality). -- Their import-attributes clauses (`with { ... }`) are structurally equal - (same keys, same string values, same order is not required). -- They do not both declare a default specifier with different local names - (e.g. `import x from "y"` + `import z from "y"` — two defaults, conflict). -- Neither carries `// dprint-ignore`. - -**Merge result:** - -- Specifier set = union of all specifiers from merged decls. -- Specifier order = defaults first, then namespace, then named (sorted by - existing `importDeclaration.sortNamedImports`). -- Type-only mixing: if at least one merged decl is value and at least one is - `import type` (or has per-specifier `type` markers), result is a value - declaration with `type` markers preserved per specifier - (`import { a, type B } from "x"`). If **all** merged decls are - `import type`, result is `import type { ... } from "x"`. -- Leading comments: concatenated in source order above the merged decl, with - blank lines between author-separated blocks preserved. -- Trailing same-line comment: only one allowed; if multiple, keep the first - and emit the rest as preceding line comments of the merged decl, source - order preserved. -- Side-effect import (`import "./x"`) followed by named import from same - source: merged to the named import (named import already triggers eval). - -**Skip cases** (no merge, original decls kept; emit info diagnostic): - -- Two default specifiers with different local names. -- Different attribute clauses. -- Either decl has `// dprint-ignore`. - -## Emission - -Touch points (`src/generation/generate.rs`, ~7300–7470): - -1. Extend `StmtGroup`: - ```rust - struct StmtGroup<'a> { - kind: StmtGroupKind, - nodes: Vec>, - subgroup_boundaries: Option>, // indices into `nodes` where a new subgroup starts - } - ``` - `subgroup_boundaries` is `None` unless `kind == Imports && !config.import_groups.is_empty()`. - -2. Add `partition_import_group(nodes, context) -> (Vec, Vec)`: - - `classify` each node → `(group_idx, node)`. - - Stable-partition by `group_idx` preserving original index for ties. - - Apply the existing node sorter within each partition. - - Concatenate in resolved-group order, recording boundary indices for non-empty partitions. - -3. `get_stmt_groups` calls `partition_import_group` when the group is an - `Imports` group and the feature is enabled; replaces `nodes` and sets - `subgroup_boundaries`. - -4. The `should_use_blank_line` predicate for an `Imports` group: - - Same subgroup → existing behavior (no forced blank line). - - Straddles a boundary → force exactly one blank line. - - Author-written blank lines inside a subgroup are normalized away (the - reorder makes preserving them meaningless). - -## Edge cases - -- **Empty config / feature off**: existing behavior preserved (regression test). -- **All imports one category**: no blank lines inserted (single non-empty subgroup). -- **Side-effect imports in the middle of imports**: already split the - `StmtGroupKind::Imports` run; each side classified independently; positions - preserved. (Matches `import/order` default behavior for side effects.) -- **`// dprint-ignore` on an import**: classification skipped for that node; it - acts as a barrier (preserves position, splits the run). -- **Author-written blank lines inside an import run, feature ON**: ignored; - blank lines are driven by group boundaries. Documented as a behavior change. -- **`import * as X from "..."`**: classified by source like any other import. -- **`import Foo, { type Bar } from "..."`**: `decl.type_only` is false → - classified as value. Only fully `import type` lines hit the `type` category. -- **Glob matches multiple groups**: first listed wins. -- **Category listed twice**: diagnostic, first occurrence used. -- **`unknown` listed explicitly**: that position is used instead of implicit end. -- **`"type"` listed under `typeImports: "interleave"`**: diagnostic; the - category never matches anything in this mode and is ignored. -- **Unknown category string** (e.g. `"buildin"` typo): config-resolve - diagnostic; entry ignored. -- **File header comments** (license, `// @ts-check`, shebang) above the first - import: detect "detached" leading comments — comments separated from the - first import by at least one blank line — and pin them to the file start. - Only comments adjacent (no blank line) to an import travel with that import - during reorder. -- **Import attributes** (`import x from "y" with { type: "json" }`): - classification reads only `decl.src.value`; attributes are not part of the - category decision. Decls are reordered intact — attribute clauses pass - through verbatim. Attributes do participate in the merge eligibility check - when `mergeImports = true` (two decls with non-equal attribute clauses are - never merged even if their sources match). -- **Multiple import chunks separated by non-import statements**: each chunk - grouped independently (existing `get_stmt_groups` chunk boundary). No - cross-chunk reorder. -- **`.d.ts` declaration files**: same code path; no special handling. -- **Imports inside `declare module "..."`**: not classified; nested module - bodies are skipped (top-level program only). -- **TS `import equals`**: not in the current `Imports` `StmtGroupKind`; - unaffected. Out of scope. -- **`export ... from`**: handled by the `Exports` `StmtGroupKind`; unaffected. - -## Interaction with existing knobs - -| Knob | Interaction | -|---------------------------------------------------|-------------------------------------------------------| -| `module.sortImportDeclarations` | Within-group sort. `Maintain` keeps source order. | -| `module.sortExportDeclarations` | Unchanged (exports unaffected). | -| `importDeclaration.sortNamedImports` | Unchanged. Specifier sort still applies. | -| `importDeclaration.sortTypeOnlyImports` | Unchanged. | -| `importDeclaration.forceSingleLine` / `preferHanging` / `preferSingleLine` | Orthogonal. Apply per-decl after reorder. | - -## Performance - -One classification call per import: string-prefix checks + `NODE_CORE_LIST` -lookup + globset match. Linear in number of imports; negligible vs. full -pretty-print. - -## Testing - -Specs live in `tests/specs/modules/imports/ImportGroups_*.txt` using the -existing dprint spec test format (input → expected, per-spec config). - -### Coverage matrix - -| # | Scenario | -|---|---| -| 1 | Feature off (empty/absent config) — identity on a mixed import block | -| 2 | ESLint mirror `[builtin, external, parent, [sibling, index]]` — reorders, inserts blanks, collapses extras | -| 3 | Single populated category — no blank lines | -| 4 | All imports unmatched — catch-all at end | -| 5 | Explicit `unknown` placement | -| 6 | `node:` prefix and bare core (`fs`) both classified `builtin` | -| 7 | Non-core bare (`react`) → `external` | -| 8 | Pattern glob `@app/**` first-match-wins | -| 9 | Category appearing twice — diagnostic + first wins | -| 10 | `typeImports: "separate"` pulls `import type` into `type` group | -| 11 | `typeImports: "interleave"` mixes `import type` with value by path | -| 12 | Mixed default+type specifier stays value | -| 13 | Side-effect import barrier — each side classified independently | -| 14 | Author-written blank lines normalized to group boundaries when feature on | -| 15 | Leading comments follow their node across reorder | -| 16 | `// dprint-ignore` import excluded and acts as barrier | -| 17 | `module.sortImportDeclarations = Maintain` — cross-group reorder, intra preserves source | -| 18 | `module.sortImportDeclarations = CaseInsensitive` — alphabetical within each group | -| 19 | TS `import equals` unaffected | -| 20 | `export ... from` unaffected | -| 21 | Reverse default order | -| 22 | Pattern group between named groups | -| 23 | Pattern group merged with named via nested array vs separate (distinctGroup analog) | -| 24 | Scoped package `@scope/pkg` → external | -| 25 | Resolver alias `@/foo` via pattern | -| 26 | `react` vs `react-dom` ordering under each `SortOrder` | -| 27 | Multi-line `import { a, b, c } from "..."` straddling a group boundary | -| 28 | Unassigned (side-effect) import between two value imports of different groups | -| 29 | First-match-wins when an import matches two pattern groups | -| 30 | Comments between two imports of different groups | -| 31 | Trailing comment on last import of a group placed correctly with blank line | -| 32 | Mixed `import` and `import type` from the same source path (both modes) | -| 33 | File with a single import — no-op | -| 34 | Interaction with `importDeclaration.forceSingleLine` (width orthogonal) | -| 35 | Interaction with `importDeclaration.sortNamedImports` (specifier sort still applies) | -| 36 | License header comment above first import stays pinned to file start after reorder | -| 37 | `// @ts-check` / shebang preservation | -| 38 | Comment directly adjacent to an import (no blank line) travels with it | -| 39 | Import attributes `import x from "y" with { type: "json" }` classification + passthrough | -| 40 | Multiple import chunks separated by a non-import statement — each chunk grouped independently | -| 41 | `.d.ts` declaration file — same behavior | -| 42 | Imports inside `declare module "..."` body — untouched | -| 43 | Unknown category string in config (typo) — diagnostic, entry ignored | -| 44 | `typeImports: "interleave"` with `"type"` listed — diagnostic, ignored | -| 45 | Duplicate-source imports with `mergeImports: false` (default) — left as-is | -| 46 | `mergeImports: true` — basic merge of `import {a} from "x"; import {b} from "x"` → `import {a, b} from "x"` | -| 47 | `mergeImports: true` — side-effect + named from same source merge to named | -| 48 | `mergeImports: true` — default + namespace from same source merge to `import x, * as y from "z"` | -| 49 | `mergeImports: true` — value + `import type` merge with per-specifier `type` markers | -| 50 | `mergeImports: true` — all-`import type` decls merge to single `import type {...}` | -| 51 | `mergeImports: true` — two conflicting defaults left unmerged + diagnostic | -| 52 | `mergeImports: true` — different `with { ... }` attributes left unmerged | -| 53 | `mergeImports: true` — `// dprint-ignore` on either decl prevents merge | -| 54 | `mergeImports: true` — comments on merged decls preserved above result | -| 55 | `mergeImports: true` interaction with `importDeclaration.sortNamedImports` — merged specifier list sorted | -| 56 | `builtinsRuntime: "node"` (default) — bare `fs` → builtin | -| 57 | `builtinsRuntime: "deno"` — bare `fs` → external (no core list); `node:fs` → builtin | -| 58 | `builtinsRuntime: "deno"` — `npm:react`, `jsr:@std/path`, `https://deno.land/x/foo/mod.ts` → external | -| 59 | `builtinsRuntime: "bun"` — `bun:test` → builtin; `bun:sqlite` → builtin; bare `fs` → builtin | -| 60 | `builtinsRuntime: "none"` — nothing classified as builtin; user-defined pattern groups handle everything | -| 61 | Invalid `builtinsRuntime` string — diagnostic, default to `"node"` | - -### Unit tests (`#[cfg(test)]`) - -- `classify` table tests over `(src, is_type, typeImports_mode) → category`. -- `node_builtins::is_node_builtin(name)` known + unknown cases. -- Config resolution: invalid `match` shapes → diagnostics with location. - -### Snapshot stability - -Full existing spec suite must produce zero diff with the feature disabled. - -## Files touched (estimate) - -- `src/configuration/types.rs` — add `ImportGroup`, `ImportMatcher`, `TypeImportsMode`, - config fields. -- `src/configuration/builder.rs` — builder methods + defaults. -- `src/configuration/resolve_config.rs` — parse + validate `module.importGroups`, - `module.typeImports`, `module.mergeImports`, `module.builtinsRuntime`; emit diagnostics. -- `src/generation/generate.rs` — extend `StmtGroup`, add - `partition_import_group`, classifier, blank-line predicate update. -- `src/utils/builtins.rs` — new file: Node core list, Bun core list, - `is_builtin(src, runtime)` helper. -- `tests/specs/modules/imports/ImportGroups_*.txt` — new spec files. - -## Migration notes for ESLint users - -| ESLint `import/order` option | dprint equivalent | -|--------------------------------------|------------------------------------------------------------| -| `groups` | `module.importGroups` (string entries; nested arrays merge) | -| `pathGroups` | `{ pattern: "..." }` entries placed positionally in `importGroups` | -| `pathGroupsExcludedImportTypes` | Not applicable — list order is precedence | -| `newlines-between: "always"` | Default behavior when feature enabled | -| `newlines-between: "never"/"ignore"` | Set `module.importGroups` to empty (feature off) | -| `alphabetize.order: "asc"` | `module.sortImportDeclarations` = `CaseInsensitive` / `CaseSensitive` | -| `alphabetize.order: "desc"` | Not supported | -| `distinctGroup` (default true) | Default; flatten by nesting array entries to merge | -| `warnOnUnassignedImports` | Not supported (dprint is a formatter, not a linter) | -| `consolidateIslands` | Not supported | From efd8e5116681736a98ae1cdd91136d09cad052b8 Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 23:11:44 +0300 Subject: [PATCH 32/33] fix(imports): bypass node-order debug assertion during reordered import emission --- src/generation/context.rs | 4 ++++ src/generation/generate.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/generation/context.rs b/src/generation/context.rs index ea181936..218b3d9b 100644 --- a/src/generation/context.rs +++ b/src/generation/context.rs @@ -76,6 +76,8 @@ 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, } @@ -114,6 +116,8 @@ 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 93a290fd..c2e682d2 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 @@ -7361,6 +7364,14 @@ fn gen_statements<'a>(inner_range: SourceRange, stmts: Vec>, context: & .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 { @@ -7417,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 { From a8c550e84340dde3f94ecc95e614f7718c223aea Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Thu, 21 May 2026 23:16:50 +0300 Subject: [PATCH 33/33] chore(imports): remove non-essential comments --- src/configuration/resolve_config.rs | 1 - src/generation/context.rs | 1 - src/generation/generate.rs | 4 ---- 3 files changed, 6 deletions(-) diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index 2ee06883..a5cec48c 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -341,7 +341,6 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) diagnostics.extend(get_unknown_property_diagnostics(config)); - // Surface compile-time diagnostics for module.importGroups. 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); diff --git a/src/generation/context.rs b/src/generation/context.rs index 218b3d9b..4365a290 100644 --- a/src/generation/context.rs +++ b/src/generation/context.rs @@ -93,7 +93,6 @@ impl<'a> Context<'a> { ) -> Context<'a> { let mut _import_group_diags: Vec = Vec::new(); let resolved_import_groups = crate::generation::imports::resolved::compile(config, &mut _import_group_diags); - // diagnostics dropped here for now; surfaced via resolve_config in a later task. Context { media_type, program, diff --git a/src/generation/generate.rs b/src/generation/generate.rs index c2e682d2..15d53fa6 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -7576,7 +7576,6 @@ fn get_stmt_groups<'a>(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec(stmts: Vec>, context: &mut Context<'a>) -> Vec