From f1a1f89d0b40dc6a4c0913dd9d332a0ec4c7b611 Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Tue, 7 Oct 2025 00:31:14 +0000 Subject: [PATCH] feat(formatter/sort-imports): Implement basic sorting with tests (#14291) Part of #14253 - For details, please refer to the code comments - Tests check idempotency too - `_sort-imports-tests.ref.snap` is a note for progress - Eventually, I will remove it --- .../src/ir_transform/sort_imports.rs | 456 ++- .../ir_transform/_sort-imports-tests.ref.snap | 3252 +++++++++++++++++ .../oxc_formatter/tests/ir_transform/mod.rs | 65 + .../tests/ir_transform/sort_imports.rs | 659 ++++ crates/oxc_formatter/tests/mod.rs | 1 + 5 files changed, 4419 insertions(+), 14 deletions(-) create mode 100644 crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap create mode 100644 crates/oxc_formatter/tests/ir_transform/mod.rs create mode 100644 crates/oxc_formatter/tests/ir_transform/sort_imports.rs create mode 100644 crates/oxc_formatter/tests/mod.rs diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports.rs b/crates/oxc_formatter/src/ir_transform/sort_imports.rs index a5e78defb4149..1c330a6c7011a 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports.rs @@ -1,32 +1,460 @@ -use crate::{formatter::format_element::document::Document, options::SortImports}; +use std::ops::Range; + +use crate::{ + JsLabels, + formatter::format_element::{ + FormatElement, LineMode, + document::Document, + tag::{LabelId, Tag}, + }, + options, +}; pub struct SortImportsTransform { - options: SortImports, + options: options::SortImports, } impl SortImportsTransform { - pub fn new(options: SortImports) -> Self { + pub fn new(options: options::SortImports) -> Self { Self { options } } + /// Transform the given `Document` by sorting import statements according to the specified options. + /// + // NOTE: `Document` and its `FormatElement`s are already well-formatted. + // It means that: + // - There is no redundant spaces, no consecutive line breaks, etc... + // - Last element is always `FormatElement::Line(Hard)`. pub fn transform<'a>(&self, document: &Document<'a>) -> Document<'a> { - let mut new_elements = Vec::with_capacity(document.len()); + // Early return for empty files + if document.len() == 1 && matches!(document[0], FormatElement::Line(LineMode::Hard)) { + return document.clone(); + } + + let prev_elements: &[FormatElement<'a>] = document; + + // Roughly speaking, sort-imports is a process of swapping lines. + // Therefore, as a preprocessing, group IR elements into line first. + // e.g. + // ``` + // [Text, Space, Text, Line, StartTag, Text, Text, EndTag, Line, ...] + // ``` + // ↓↓ + // ``` + // [ [Text, Space, Text], [StartTag, Text, Text, EndTag], [...] ] + // ``` + // + // This is also meaningful to explicitly handle comment line, empty line, + // and other line with or without import statement. + // + // NOTE: `FormatElement::Line(_)` may not exactly correspond to an actual line break in the output. + // e.g. `LineMode::SoftOrSpace` may be rendered as a space. + // + // And this implementation is based on the following assumptions: + // - Only `Line(Hard|Empty)` is used for joining `Program.body` in the output + // - `Line(Hard|Empty)` does not appear inside an `ImportDeclaration` formatting + // - In case of this, we should check `Tag::StartLabelled(JsLabels::ImportDeclaration)` + let mut lines = vec![]; + let mut current_line_start = 0; + for (idx, el) in prev_elements.iter().enumerate() { + if let FormatElement::Line(mode) = el + && matches!(mode, LineMode::Empty | LineMode::Hard) + { + // Flush current line + if current_line_start < idx { + lines.push(SourceLine::from_element_range( + prev_elements, + current_line_start..idx, + *mode, + )); + } + current_line_start = idx + 1; - // TODO: THESE ARE DUMMY IMPLEMENTATIONS! - let mut temp = None; - for (idx, element) in document.iter().enumerate() { - if idx == 0 { - temp = Some(element); - continue; + // We need this explicitly to detect boundaries later. + if matches!(mode, LineMode::Empty) { + lines.push(SourceLine::Empty); + } } + } + if current_line_start < prev_elements.len() { + unreachable!("`Document` must end with a `FormatElement::Line(Hard)`."); + } - new_elements.push(element.clone()); + // Next, partition `SourceLine`s into `PartitionedChunk`s. + // + // Within each chunk, we will sort import lines. + // e.g. + // ``` + // import C from "c"; // chunk1 + // import B from "b"; // chunk1 + // const THIS_IS_BOUNDARY = true; + // import Z from "z"; // chunk2 + // import A from "a"; // chunk2 + // ``` + // ↓↓ + // ``` + // import B from "b"; // chunk1 + // import C from "c"; // chunk1 + // const THIS_IS_BOUNDARY = true; + // import A from "a"; // chunk2 + // import Z from "z"; // chunk2 + // ``` + let mut chunks = vec![]; + let mut current_chunk = PartitionedChunk::default(); + for line in lines { + match line { + // `SourceLine::Import` never be a boundary. + SourceLine::Import(..) => { + current_chunk.add_imports_line(line); + } + // `SourceLine::Empty` and `SourceLine::CommentOnly` can be boundaries depending on options. + // Otherwise, they will be the leading/trailing lines of `PartitionedChunk::Imports`. + SourceLine::Empty if !self.options.partition_by_newline => { + current_chunk.add_imports_line(line); + } + // TODO: Support more flexible comment handling? + // e.g. Specific text by regex, only line comments, etc. + SourceLine::CommentOnly(..) if !self.options.partition_by_comment => { + current_chunk.add_imports_line(line); + } + // This `SourceLine` is a boundary! + // Generally, `SourceLine::Others` should always reach here. + _ => { + // Flush current import chunk + if !current_chunk.is_empty() { + chunks.push(std::mem::take(&mut current_chunk)); + } + // Add boundary chunk + chunks.push(PartitionedChunk::Boundary(line)); + } + } + } + if !current_chunk.is_empty() { + chunks.push(current_chunk); } - if let Some(temp) = temp { - new_elements.insert(new_elements.len() - 1, temp.clone()); + // Finally, sort import lines within each chunk. + // After sorting, flatten everything back to `FormatElement`s. + let mut next_elements = vec![]; + + let mut chunks_iter = chunks.into_iter().enumerate().peekable(); + while let Some((idx, chunk)) = chunks_iter.next() { + match chunk { + // Boundary chunks: Just output as-is + PartitionedChunk::Boundary(line) => { + line.write(prev_elements, &mut next_elements, true); + } + // Import chunks: Sort and output + PartitionedChunk::Imports(_) => { + // For ease of implementation, we will convert `ImportChunk` into multiple `SortableImport`s. + // + // `SortableImport` is a logical unit of 1 import statement + its N leading lines. + // And there may be trailing lines after all import statements in the chunk. + // e.g. + // ``` + // const THIS_IS_BOUNDARY = true; + // // comment for A + // import A from "a"; // sortable1 + // import B from "b"; // sortable2 + // + // // comment for C and empty line above + below + // + // // another comment for C + // import C from "c"; // sortable3 + // // trailing comment and empty line below for this chunk + // + // const YET_ANOTHER_BOUNDARY = true; + // ``` + let (mut import_units, trailing_lines) = chunk.into_import_units(prev_elements); + + // Perform sorting if needed + if 1 < import_units.len() { + // TODO: Sort based on `options.groups`, `options.type`, etc... + // TODO: Consider `options.ignore_case`, `special_characters`, removing `?raw`, etc... + import_units.sort_by_key(|unit| unit.get_source(prev_elements)); + } + + let preserve_empty_line = self.options.partition_by_newline; + + // Output sorted import units + for SortableImport { leading_lines, import_line } in &import_units { + for line in leading_lines { + line.write(prev_elements, &mut next_elements, preserve_empty_line); + } + import_line.write(prev_elements, &mut next_elements, false); + } + // And output trailing lines + // + // Special care is needed for the last empty line. + // We should preserve it only if the next chunk is a boundary. + // e.g. + // ``` + // import A from "a"; // chunk1 + // import B from "b"; // chunk1 + // // This empty line should be preserved because the next chunk is a boundary. + // + // const BOUNDARY = true; // chunk2 + // ``` + // But in this case, we should not preserve it. + // ``` + // import A from "a"; // chunk1 + // import B from "b"; // chunk1 + // // This empty line should NOT be preserved because the next chunk is NOT a boundary. + // + // import C from "c"; // chunk2 + // ``` + let next_chunk_is_boundary = chunks_iter + .peek() + .is_some_and(|(_, c)| matches!(c, PartitionedChunk::Boundary(_))); + for (idx, line) in trailing_lines.iter().enumerate() { + let is_last_empty_line = + idx == trailing_lines.len() - 1 && matches!(line, SourceLine::Empty); + let preserve_empty_line = + if is_last_empty_line { next_chunk_is_boundary } else { true }; + line.write(prev_elements, &mut next_elements, preserve_empty_line); + } + } + } } - Document::from(new_elements) + Document::from(next_elements) + } +} + +#[derive(Debug, Clone)] +enum SourceLine { + /// Line that contains an import statement. + /// May have leading comments like `/* ... */ import ...`. + /// And also may have trailing comments like `import ...; // ...`. + /// + /// The 2nd field is the index of the original `elements` for import `source`. + /// The 3rd field indicates whether this is a side-effect-only import. + /// + /// Never be a boundary. + // TODO: Consider using struct + Import(Range, usize, bool), + /// Empty line. + /// May be used as a boundary if `options.partition_by_newline` is true. + Empty, + /// Line that contains only comment(s). + /// May be used as a boundary if `options.partition_by_comment` is true. + CommentOnly(Range, LineMode), + /// Other lines, always a boundary. + Others(Range, LineMode), +} + +impl SourceLine { + fn from_element_range( + elements: &[FormatElement], + range: Range, + line_mode: LineMode, + ) -> Self { + debug_assert!( + !range.is_empty(), + "`range` must not be empty, otherwise use `SourceLine::Empty` directly." + ); + + // Check if the line is comment-only. + // e.g. + // ``` + // // comment + // /* comment */ + // /* comment */ // comment + // /* comment */ /* comment */ + // ``` + let is_comment_only = range.clone().all(|idx| match &elements[idx] { + FormatElement::DynamicText { text } => text.starts_with("//") || text.starts_with("/*"), + FormatElement::Line(LineMode::Soft | LineMode::SoftOrSpace) | FormatElement::Space => { + true + } + _ => false, + }); + if is_comment_only { + // TODO: Check it contains ignore comment? + return SourceLine::CommentOnly(range, line_mode); + } + + // Check if the line contains an import statement. + // Sometimes, there might be leading comments in the same line, + // so we need to check all elements in the line to find an `ImportDeclaration`. + // ``` + // /* THIS */ import ... + // import ... + // ``` + let mut has_import = false; + let mut source_idx = None; + let mut is_side_effect_import = true; + for idx in range.clone() { + match &elements[idx] { + // Special marker for `ImportDeclaration` + FormatElement::Tag(Tag::StartLabelled(id)) + if *id == LabelId::of(JsLabels::ImportDeclaration) => + { + has_import = true; + } + FormatElement::StaticText { text } => { + if has_import && *text == "from" { + is_side_effect_import = false; + // Reset `source_idx` to ensure we get the text after "from". + // `ImportSpecifier` may appear before `source`. + source_idx = None; + } + } + // `ImportDeclaration.source: StringLiteral` is formatted as either: + // - `LocatedTokenText` (when borrowed, quote unchanged) + // - `DynamicText` (when owned, quote normalized) + FormatElement::LocatedTokenText { .. } | FormatElement::DynamicText { .. } => { + if has_import && source_idx.is_none() { + source_idx = Some(idx); + } + } + _ => {} + } + } + if has_import && let Some(source_idx) = source_idx { + // TODO: Check line has trailing ignore comment? + return SourceLine::Import(range, source_idx, is_side_effect_import); + } + + // Otherwise, this line is neither of: + // - Empty line + // - Comment-only line + // - Import line + // So, it will be a boundary line. + SourceLine::Others(range, line_mode) + } + + fn write<'a>( + &self, + prev_elements: &[FormatElement<'a>], + next_elements: &mut Vec>, + preserve_empty_line: bool, + ) { + match self { + SourceLine::Empty => { + // Skip empty lines unless they should be preserved + if preserve_empty_line { + next_elements.push(FormatElement::Line(LineMode::Empty)); + } + } + SourceLine::Import(range, ..) => { + for idx in range.clone() { + next_elements.push(prev_elements[idx].clone()); + } + // Always use hard line break after import statement. + next_elements.push(FormatElement::Line(LineMode::Hard)); + } + SourceLine::CommentOnly(range, mode) | SourceLine::Others(range, mode) => { + for idx in range.clone() { + next_elements.push(prev_elements[idx].clone()); + } + next_elements.push(FormatElement::Line(*mode)); + } + } + } +} + +#[derive(Debug, Clone)] +enum PartitionedChunk { + /// A chunk containing import statements, + /// and possibly leading/trailing comments or empty lines. + Imports(Vec), + /// A boundary chunk. + /// Always contains `SourceLine::Others`, + /// or optionally `SourceLine::Empty|CommentOnly` depending on partition options. + Boundary(SourceLine), +} + +impl Default for PartitionedChunk { + fn default() -> Self { + Self::Imports(vec![]) + } +} + +impl PartitionedChunk { + fn add_imports_line(&mut self, line: SourceLine) { + debug_assert!( + !matches!(line, SourceLine::Others(..)), + "`line` must not be of type `SourceLine::Others`." + ); + + match self { + Self::Imports(lines) => lines.push(line), + Self::Boundary(_) => { + unreachable!("Cannot add to a boundary chunk"); + } + } + } + + fn is_empty(&self) -> bool { + matches!(self, Self::Imports(lines) if lines.is_empty()) + } + + #[must_use] + fn into_import_units( + self, + elements: &[FormatElement], + ) -> (Vec, Vec) { + let Self::Imports(lines) = self else { + unreachable!( + "`into_import_units()` must be called on `PartitionedChunk::Imports` only." + ); + }; + + let mut units = vec![]; + + let mut current_leading_lines = vec![]; + for line in lines { + match line { + SourceLine::Import(..) => { + units.push(SortableImport::new( + std::mem::take(&mut current_leading_lines), + line, + )); + } + SourceLine::CommentOnly(..) | SourceLine::Empty => { + current_leading_lines.push(line); + } + SourceLine::Others(..) => { + unreachable!( + "`PartitionedChunk::Imports` must not contain `SourceLine::Others`." + ); + } + } + } + + // Any remaining comments/lines are trailing + let trailing_lines = current_leading_lines; + + (units, trailing_lines) + } +} + +#[derive(Debug, Clone)] +struct SortableImport { + leading_lines: Vec, + import_line: SourceLine, +} + +impl SortableImport { + fn new(leading_lines: Vec, import_line: SourceLine) -> Self { + Self { leading_lines, import_line } + } + + /// Get the import source string for sorting. + /// e.g. `"./foo"`, `"react"`, etc... + /// This includes quotes, but will not affect sorting. + /// Since they are already normalized by the formatter. + fn get_source<'a>(&self, elements: &'a [FormatElement]) -> &'a str { + let SourceLine::Import(_, source_idx, _) = &self.import_line else { + unreachable!("`import_line` must be of type `SourceLine::Import`."); + }; + match &elements[*source_idx] { + FormatElement::LocatedTokenText { slice, .. } => slice, + FormatElement::DynamicText { text } => text, + _ => unreachable!( + "`source_idx` must point to either `LocatedTokenText` or `DynamicText` in the `elements`." + ), + } } } diff --git a/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap b/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap new file mode 100644 index 0000000000000..09aa3ac7c5a9e --- /dev/null +++ b/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap @@ -0,0 +1,3252 @@ +// @ts-nocheck +// vim: set filetype=typescript: +describe("alphabetical", () => { + let options = { + type: "alphabetical", + order: "asc", + } as const; + + it("groups and sorts imports by type and source", async () => { + await valid({ + code: dedent` + import type { T } from 't' + + import { c1, c2, c3, c4 } from 'c' + import { e1 } from 'e/a' + import { e2 } from 'e/b' + import fs from 'fs' + import path from 'path' + + import type { I } from '~/i' + + import { b1, b2 } from '~/b' + import { c1 } from '~/c' + import { i1, i2, i3 } from '~/i' + + import type { A } from '.' + import type { F } from '../f' + import type { D } from './d' + import type { H } from './index.d.ts' + + import a from '.' + import h from '../../h' + import { j } from '../j' + import { K, L, M } from '../k' + import './style.css' + `, + options: [options], + }); + + await invalid({ + output: dedent` + import type { T } from 't' + + import { c1, c2, c3, c4 } from 'c' + import { e1 } from 'e/a' + import { e2 } from 'e/b' + import fs from 'fs' + import path from 'path' + + import type { I } from '~/i' + + import { b1, b2 } from '~/b' + import { c1 } from '~/c' + import { i1, i2, i3 } from '~/i' + + import type { A } from '.' + import type { F } from '../f' + import type { D } from './d' + import type { H } from './index.d.ts' + + import a from '.' + import h from '../../h' + import './style.css' + import { j } from '../j' + import { K, L, M } from '../k' + `, + code: dedent` + import { c1, c2, c3, c4 } from 'c' + import { e2 } from 'e/b' + import { e1 } from 'e/a' + import path from 'path' + + import { b1, b2 } from '~/b' + import type { I } from '~/i' + import type { D } from './d' + import fs from 'fs' + import { c1 } from '~/c' + import { i1, i2, i3 } from '~/i' + + import type { A } from '.' + import type { F } from '../f' + import h from '../../h' + import type { H } from './index.d.ts' + + import a from '.' + import type { T } from 't' + import './style.css' + import { j } from '../j' + import { K, L, M } from '../k' + `, + options: [options], + }); + }); + + it("sorts imports without spacing between groups when configured", async () => { + await valid({ + code: dedent` + import type { T } from 't' + import { a1, a2, a3 } from 'a' + import { b1, b2 } from '~/b' + import { c1, c2, c3 } from '~/c' + import d from '.' + import { e1, e2, e3 } from '../../e' + `, + options: [ + { + ...options, + newlinesBetween: "never", + }, + ], + }); + + await invalid({ + code: dedent` + import d from '.' + import { a1, a2, a3 } from 'a' + import { c1, c2, c3 } from '~/c' + + import type { T } from 't' + import { e1, e2, e3 } from '../../e' + + import { b1, b2 } from '~/b' + `, + output: dedent` + import type { T } from 't' + import { a1, a2, a3 } from 'a' + import { b1, b2 } from '~/b' + import { c1, c2, c3 } from '~/c' + import d from '.' + import { e1, e2, e3 } from '../../e' + `, + options: [ + { + ...options, + newlinesBetween: "never", + }, + ], + }); + }); + + it("handles TypeScript import-equals syntax correctly", async () => { + await valid({ + code: dedent` + import type T = require("T") + + import { A } from 'a' + import c = require('c/c') + + import { B } from '../b' + + import log = console.log + `, + options: [options], + }); + + await invalid({ + output: dedent` + import type T = require("T") + + import { A } from 'a' + import c = require('c/c') + + import { B } from '../b' + + import log = console.log + `, + code: dedent` + import type T = require("T") + + import { A } from 'a' + import { B } from '../b' + + import log = console.log + import c = require('c/c') + `, + options: [options], + }); + }); + + it("groups all type imports together when specific type groups not configured", async () => { + await valid({ + options: [ + { + ...options, + groups: [ + "type", + ["builtin", "external"], + "internal", + ["parent", "sibling", "index"], + ], + }, + ], + code: dedent` + import type { T } from '../t' + import type { U } from '~/u' + import type { V } from 'v' + `, + }); + + await invalid({ + options: [ + { + ...options, + groups: [ + "type", + ["builtin", "external"], + "internal", + ["parent", "sibling", "index"], + ], + }, + ], + code: dedent` + import type { T } from '../t' + + import type { U } from '~/u' + + import type { V } from 'v' + `, + output: dedent` + import type { T } from '../t' + import type { U } from '~/u' + import type { V } from 'v' + `, + }); + }); + + it("ignores comments when calculating spacing between imports", async () => { + await valid({ + code: dedent` + import type { T } from 't' + + // @ts-expect-error missing types + import { t } from 't' + `, + options: [options], + }); + }); + + it("groups style imports separately when configured", async () => { + await valid({ + options: [ + { + ...options, + groups: [ + "type", + ["builtin", "external"], + "internal-type", + "internal", + ["parent-type", "sibling-type", "index-type"], + ["parent", "sibling", "index"], + "style", + "unknown", + ], + }, + ], + code: dedent` + import { a1, a2 } from 'a' + + import styles from '../s.css' + import './t.css' + `, + }); + }); + + it("groups side-effect imports separately when configured", async () => { + await valid({ + options: [ + { + ...options, + groups: [ + "type", + ["builtin", "external"], + "internal-type", + "internal", + ["parent-type", "sibling-type", "index-type"], + ["parent", "sibling", "index"], + "side-effect", + "unknown", + ], + }, + ], + code: dedent` + import { A } from '../a' + import { b } from './b' + + import '../c.js' + import './d' + `, + }); + }); + + it("groups builtin types separately from other type imports", async () => { + await valid({ + options: [ + { + ...options, + groups: ["builtin-type", "type"], + }, + ], + code: dedent` + import type { Server } from 'http' + + import a from 'a' + `, + }); + }); + + it("handles imports with semicolons correctly", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + "type", + ["builtin", "external"], + "internal-type", + "internal", + ["parent-type", "sibling-type", "index-type"], + ["parent", "sibling", "index"], + "unknown", + ], + }, + ], + output: dedent` + import a from 'a'; + + import b from './index'; + `, + code: dedent` + import a from 'a'; + import b from './index'; + `, + }); + }); + + it("supports custom import groups with primary and secondary categories", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + "type", + "primary", + "secondary", + ["builtin", "external"], + "internal-type", + "internal", + ["parent-type", "sibling-type", "index-type"], + ["parent", "sibling", "index"], + "unknown", + ], + customGroups: { + value: { + primary: ["t", "@a/.+"], + secondary: "@b/.+", + }, + type: { + primary: ["t", "@a/.+"], + }, + }, + }, + ], + output: dedent` + import a1 from '@a/a1' + import a2 from '@a/a2' + import type { T } from 't' + + import b1 from '@b/b1' + import b2 from '@b/b2' + import b3 from '@b/b3' + + import { c } from 'c' + `, + code: dedent` + import type { T } from 't' + + import a1 from '@a/a1' + import a2 from '@a/a2' + import b1 from '@b/b1' + import b2 from '@b/b2' + import b3 from '@b/b3' + import { c } from 'c' + `, + }); + }); + + it("supports custom groups for value imports only", async () => { + await invalid({ + options: [ + { + ...options, + customGroups: { + value: { + primary: ["a"], + }, + }, + groups: ["type", "primary"], + }, + ], + output: dedent` + import type { A } from 'a' + + import { a } from 'a' + `, + code: dedent` + import type { A } from 'a' + import { a } from 'a' + `, + }); + }); + + it("handles hash symbol in internal patterns correctly", async () => { + await valid({ + code: dedent` + import type { T } from 'a' + + import { a } from 'a' + + import type { S } from '#b' + + import { b1, b2 } from '#b' + import c from '#c' + + import { d } from '../d' + `, + options: [ + { + ...options, + internalPattern: ["#.+"], + }, + ], + }); + + await invalid({ + output: dedent` + import type { T } from 'a' + + import { a } from 'a' + + import type { S } from '#b' + + import { b1, b2 } from '#b' + import c from '#c' + + import { d } from '../d' + `, + code: dedent` + import type { T } from 'a' + + import { a } from 'a' + + import type { S } from '#b' + import c from '#c' + import { b1, b2 } from '#b' + + import { d } from '../d' + `, + options: [ + { + ...options, + internalPattern: ["#.+"], + }, + ], + }); + }); + + it("recognizes Bun built-in modules when configured", async () => { + await valid({ + options: [ + { + ...options, + groups: ["builtin", "external", "unknown"], + newlinesBetween: "never", + environment: "bun", + }, + ], + code: dedent` + import { expect } from 'bun:test' + import { a } from 'a' + `, + }); + + await invalid({ + options: [ + { + ...options, + groups: ["builtin", "external", "unknown"], + newlinesBetween: "never", + environment: "bun", + }, + ], + output: dedent` + import { expect } from 'bun:test' + import { a } from 'a' + `, + code: dedent` + import { a } from 'a' + import { expect } from 'bun:test' + `, + }); + }); + + it("sorts CommonJS require imports by module name", async () => { + await valid({ + code: dedent` + const { a1, a2 } = require('a') + const { b1 } = require('b') + `, + options: [options], + }); + + await invalid({ + output: dedent` + const { a1, a2 } = require('a') + const { b1 } = require('b') + `, + code: dedent` + const { b1 } = require('b') + const { a1, a2 } = require('a') + `, + options: [options], + }); + }); + + it("groups and sorts CommonJS require imports by type and source", async () => { + await valid({ + code: dedent` + const { c1, c2, c3, c4 } = require('c') + const { e1 } = require('e/a') + const { e2 } = require('e/b') + const fs = require('fs') + const path = require('path') + + const { b1, b2 } = require('~/b') + const { c1 } = require('~/c') + const { i1, i2, i3 } = require('~/i') + + const a = require('.') + const h = require('../../h') + const { j } = require('../j') + const { K, L, M } = require('../k') + `, + options: [options], + }); + + await invalid({ + output: dedent` + const { c1, c2, c3, c4 } = require('c') + const { e1 } = require('e/a') + const { e2 } = require('e/b') + const fs = require('fs') + const path = require('path') + + const { b1, b2 } = require('~/b') + const { c1 } = require('~/c') + const { i1, i2, i3 } = require('~/i') + + const a = require('.') + const h = require('../../h') + const { j } = require('../j') + const { K, L, M } = require('../k') + `, + code: dedent` + const { c1, c2, c3, c4 } = require('c') + const { e2 } = require('e/b') + const { e1 } = require('e/a') + const path = require('path') + + const { b1, b2 } = require('~/b') + const fs = require('fs') + const { c1 } = require('~/c') + const { i1, i2, i3 } = require('~/i') + + const h = require('../../h') + + const a = require('.') + const { j } = require('../j') + const { K, L, M } = require('../k') + `, + options: [options], + }); + }); + + it("preserves side-effect import order when sorting disabled", async () => { + await valid({ + options: [ + { + ...options, + groups: ["external", "side-effect", "unknown"], + sortSideEffects: false, + }, + ], + code: dedent` + import a from 'aaaa' + + import 'bbb' + import './cc' + import '../d' + `, + }); + + await valid({ + options: [ + { + ...options, + groups: ["external", "side-effect", "unknown"], + sortSideEffects: false, + }, + ], + code: dedent` + import 'c' + import 'bb' + import 'aaa' + `, + }); + + await invalid({ + options: [ + { + ...options, + groups: ["external", "side-effect", "unknown"], + sortSideEffects: false, + }, + ], + output: dedent` + import a from 'aaaa' + import e from 'e' + + import './cc' + import 'bbb' + import '../d' + `, + code: dedent` + import './cc' + import 'bbb' + import e from 'e' + import a from 'aaaa' + import '../d' + `, + }); + }); + + it("sorts side-effect imports when sorting enabled", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["external", "side-effect", "unknown"], + sortSideEffects: true, + }, + ], + output: dedent` + import 'aaa' + import 'bb' + import 'c' + `, + code: dedent` + import 'c' + import 'bb' + import 'aaa' + `, + }); + }); + + it("preserves original order when side-effect imports are not grouped", async () => { + await invalid({ + output: dedent` + import "./z-side-effect.scss"; + import a from "./a"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + import b from "./b"; + `, + code: dedent` + import "./z-side-effect.scss"; + import b from "./b"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + import a from "./a"; + `, + options: [ + { + ...options, + groups: ["unknown"], + }, + ], + }); + }); + + it("groups side-effect imports together without sorting them", async () => { + await invalid({ + output: dedent` + import "./z-side-effect.scss"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + + import a from "./a"; + import b from "./b"; + `, + code: dedent` + import "./z-side-effect.scss"; + import b from "./b"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + import a from "./a"; + `, + options: [ + { + ...options, + groups: ["side-effect", "unknown"], + }, + ], + }); + }); + + it("groups side-effect and style imports together in same group without sorting", async () => { + await invalid({ + output: dedent` + import "./z-side-effect.scss"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + + import a from "./a"; + import b from "./b"; + `, + code: dedent` + import "./z-side-effect.scss"; + import b from "./b"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + import a from "./a"; + `, + options: [ + { + ...options, + groups: [["side-effect", "side-effect-style"], "unknown"], + }, + ], + }); + }); + + it("separates side-effect and style imports into distinct groups without sorting", async () => { + await invalid({ + output: dedent` + import './b-side-effect' + import './a-side-effect' + + import "./z-side-effect.scss"; + import "./g-side-effect.css"; + + import a from "./a"; + import b from "./b"; + `, + code: dedent` + import "./z-side-effect.scss"; + import b from "./b"; + import './b-side-effect' + import "./g-side-effect.css"; + import './a-side-effect' + import a from "./a"; + `, + options: [ + { + ...options, + groups: ["side-effect", "side-effect-style", "unknown"], + }, + ], + }); + }); + + it("groups style side-effect imports separately without sorting", async () => { + await invalid({ + output: dedent` + import "./z-side-effect"; + import './b-side-effect.scss' + import './a-side-effect.css' + + import "./g-side-effect"; + import a from "./a"; + import b from "./b"; + `, + code: dedent` + import "./z-side-effect"; + import b from "./b"; + import './b-side-effect.scss' + import "./g-side-effect"; + import './a-side-effect.css' + import a from "./a"; + `, + options: [ + { + ...options, + groups: ["side-effect-style", "unknown"], + }, + ], + }); + }); + + it("ignores fallback sorting for side-effect imports", async () => { + await valid({ + options: [ + { + groups: ["side-effect", "side-effect-style"], + fallbackSort: { type: "alphabetical" }, + }, + ], + code: dedent` + import 'b'; + import 'a'; + + import 'b.css'; + import 'a.css'; + `, + }); + }); + + it("handles special characters with trim option", async () => { + await valid({ + options: [ + { + ...options, + specialCharacters: "trim", + }, + ], + code: dedent` + import '_a' + import 'b' + import '_c' + `, + }); + }); + + it("handles special characters with remove option", async () => { + await valid({ + options: [ + { + ...options, + specialCharacters: "remove", + }, + ], + code: dedent` + import 'ab' + import 'a_c' + `, + }); + }); + + it("supports locale-specific sorting", async () => { + await valid({ + code: dedent` + import '你好' + import '世界' + import 'a' + import 'A' + import 'b' + import 'B' + `, + options: [{ ...options, locales: "zh-CN" }], + }); + }); + + it.each([ + ["removes newlines with never option", "never"], + ["removes newlines with 0 option", 0], + ])("%s", async (_description, newlinesBetween) => { + await invalid({ + code: dedent` + import { A } from 'a' + + + import y from '~/y' + import z from '~/z' + + import b from '~/b' + `, + output: dedent` + import { A } from 'a' + import b from '~/b' + import y from '~/y' + import z from '~/z' + `, + options: [ + { + ...options, + newlinesBetween, + }, + ], + }); + }); + + it("handles custom spacing rules between consecutive groups", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + "a", + { newlinesBetween: "always" }, + "b", + { newlinesBetween: "always" }, + "c", + { newlinesBetween: "never" }, + "d", + { newlinesBetween: "ignore" }, + "e", + ], + customGroups: { + value: { + a: "a", + b: "b", + c: "c", + d: "d", + e: "e", + }, + }, + newlinesBetween: "always", + }, + ], + output: dedent` + import { A } from 'a' + + import { B } from 'b' + + import { C } from 'c' + import { D } from 'd' + + + import { E } from 'e' + `, + code: dedent` + import { A } from 'a' + import { B } from 'b' + + + import { C } from 'c' + + import { D } from 'd' + + + import { E } from 'e' + `, + }); + }); + + it.each([ + [ + "enforces spacing when global option is 2 and group option is never", + 2, + "never", + ], + ["enforces spacing when global option is 2 and group option is 0", 2, 0], + [ + "enforces spacing when global option is 2 and group option is ignore", + 2, + "ignore", + ], + [ + "enforces spacing when global option is never and group option is 2", + "never", + 2, + ], + ["enforces spacing when global option is 0 and group option is 2", 0, 2], + [ + "enforces spacing when global option is ignore and group option is 2", + "ignore", + 2, + ], + ])( + "%s", + async (_description, globalNewlinesBetween, groupNewlinesBetween) => { + await invalid({ + options: [ + { + ...options, + customGroups: { + value: { + unusedGroup: "X", + a: "a", + b: "b", + }, + }, + groups: [ + "a", + "unusedGroup", + { newlinesBetween: groupNewlinesBetween }, + "b", + ], + newlinesBetween: globalNewlinesBetween, + }, + ], + output: dedent` + import { a } from 'a'; + + + import { b } from 'b'; + `, + code: dedent` + import { a } from 'a'; + import { b } from 'b'; + `, + }); + }, + ); + + it.each([ + [ + "removes spacing when never option exists between groups regardless of global setting always", + "always", + ], + [ + "removes spacing when never option exists between groups regardless of global setting 2", + 2, + ], + [ + "removes spacing when never option exists between groups regardless of global setting ignore", + "ignore", + ], + [ + "removes spacing when never option exists between groups regardless of global setting never", + "never", + ], + [ + "removes spacing when never option exists between groups regardless of global setting 0", + 0, + ], + ])("%s", async (_description, globalNewlinesBetween) => { + await invalid({ + options: [ + { + ...options, + groups: [ + "a", + { newlinesBetween: "never" }, + "unusedGroup", + { newlinesBetween: "never" }, + "b", + { newlinesBetween: "always" }, + "c", + ], + customGroups: { + value: { + unusedGroup: "X", + a: "a", + b: "b", + c: "c", + }, + }, + newlinesBetween: globalNewlinesBetween, + }, + ], + output: dedent` + import { a } from 'a'; + import { b } from 'b'; + `, + code: dedent` + import { a } from 'a'; + + import { b } from 'b'; + `, + }); + }); + + it.each([ + [ + "preserves existing spacing when ignore and never options are combined", + "ignore", + "never", + ], + [ + "preserves existing spacing when ignore and 0 options are combined", + "ignore", + 0, + ], + [ + "preserves existing spacing when never and ignore options are combined", + "never", + "ignore", + ], + [ + "preserves existing spacing when 0 and ignore options are combined", + 0, + "ignore", + ], + ])( + "%s", + async (_description, globalNewlinesBetween, groupNewlinesBetween) => { + await valid({ + options: [ + { + ...options, + customGroups: { + value: { + unusedGroup: "X", + a: "a", + b: "b", + }, + }, + groups: [ + "a", + "unusedGroup", + { newlinesBetween: groupNewlinesBetween }, + "b", + ], + newlinesBetween: globalNewlinesBetween, + }, + ], + code: dedent` + import { a } from 'a'; + + import { b } from 'b'; + `, + }); + + await valid({ + options: [ + { + ...options, + customGroups: { + value: { + unusedGroup: "X", + a: "a", + b: "b", + }, + }, + groups: [ + "a", + "unusedGroup", + { newlinesBetween: groupNewlinesBetween }, + "b", + ], + newlinesBetween: globalNewlinesBetween, + }, + ], + code: dedent` + import { a } from 'a'; + import { b } from 'b'; + `, + }); + }, + ); + + it.each([ + [ + "ignores newline fixes between different partitions with never option", + "never", + ], + ["ignores newline fixes between different partitions with 0 option", 0], + ])("%s", async (_description, newlinesBetween) => { + await invalid({ + options: [ + { + ...options, + customGroups: [ + { + elementNamePattern: "a", + groupName: "a", + }, + ], + groups: ["a", "unknown"], + partitionByComment: true, + newlinesBetween, + }, + ], + output: dedent` + import a from 'a'; + + // Partition comment + + import { b } from './b'; + import { c } from './c'; + `, + code: dedent` + import a from 'a'; + + // Partition comment + + import { c } from './c'; + import { b } from './b'; + `, + }); + }); + + it("allows partitioning by comment patterns", async () => { + await invalid({ + output: dedent` + // Part: A + // Not partition comment + import bbb from './bbb'; + import cc from './cc'; + import d from './d'; + // Part: B + import aaaa from './aaaa'; + import e from './e'; + // Part: C + // Not partition comment + import fff from './fff'; + import gg from './gg'; + `, + code: dedent` + // Part: A + import cc from './cc'; + import d from './d'; + // Not partition comment + import bbb from './bbb'; + // Part: B + import aaaa from './aaaa'; + import e from './e'; + // Part: C + import gg from './gg'; + // Not partition comment + import fff from './fff'; + `, + options: [ + { + ...options, + partitionByComment: "^Part", + }, + ], + }); + }); + + it("supports regex patterns for partition comments", async () => { + await valid({ + code: dedent` + import e from './e' + import f from './f' + // I am a partition comment because I don't have f o o + import a from './a' + import b from './b' + `, + options: [ + { + ...options, + partitionByComment: ["^(?!.*foo).*$"], + }, + ], + }); + }); + + it("ignores block comments when line comment partitioning is enabled", async () => { + await invalid({ + options: [ + { + ...options, + partitionByComment: { + line: true, + }, + }, + ], + output: dedent` + /* Comment */ + import a from './a' + import b from './b' + `, + code: dedent` + import b from './b' + /* Comment */ + import a from './a' + `, + }); + }); + + it("treats all line comments as partition boundaries when enabled", async () => { + await valid({ + options: [ + { + ...options, + partitionByComment: { + line: true, + }, + }, + ], + code: dedent` + import b from './b' + // Comment + import a from './a' + `, + }); + }); + + it("supports multiple line comment patterns for partitioning", async () => { + await valid({ + options: [ + { + ...options, + partitionByComment: { + line: ["a", "b"], + }, + }, + ], + code: dedent` + import c from './c' + // b + import b from './b' + // a + import a from './a' + `, + }); + }); + + it("supports regex patterns for line comment partitioning", async () => { + await valid({ + options: [ + { + ...options, + partitionByComment: { + line: ["^(?!.*foo).*$"], + }, + }, + ], + code: dedent` + import b from './b' + // I am a partition comment because I don't have f o o + import a from './a' + `, + }); + }); + + it("ignores line comments when block comment partitioning is enabled", async () => { + await invalid({ + options: [ + { + ...options, + partitionByComment: { + block: true, + }, + }, + ], + output: dedent` + // Comment + import a from './a' + import b from './b' + `, + code: dedent` + import b from './b' + // Comment + import a from './a' + `, + }); + }); + + it("treats all block comments as partition boundaries when enabled", async () => { + await valid({ + options: [ + { + ...options, + partitionByComment: { + block: true, + }, + }, + ], + code: dedent` + import b from './b' + /* Comment */ + import a from './a' + `, + }); + }); + + it("supports multiple block comment patterns for partitioning", async () => { + await valid({ + options: [ + { + ...options, + partitionByComment: { + block: ["a", "b"], + }, + }, + ], + code: dedent` + import c from './c' + /* b */ + import b from './b' + /* a */ + import a from './a' + `, + }); + }); + + it("supports regex patterns for block comment partitioning", async () => { + await valid({ + options: [ + { + ...options, + partitionByComment: { + block: ["^(?!.*foo).*$"], + }, + }, + ], + code: dedent` + import b from './b' + /* I am a partition comment because I don't have f o o */ + import a from './a' + `, + }); + }); + + it("prioritizes index types over sibling types", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["index-type", "sibling-type"], + }, + ], + output: dedent` + import type b from './index' + + import type a from './a' + `, + code: dedent` + import type a from './a' + + import type b from './index' + `, + }); + }); + + it("prioritizes specific type selectors over generic type group", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + [ + "index-type", + "internal-type", + "external-type", + "sibling-type", + "builtin-type", + ], + "type", + ], + }, + ], + output: dedent` + import type b from './b' + import type c from './index' + import type d from 'd' + import type e from 'timers' + + import type a from '../a' + `, + code: dedent` + import type a from '../a' + + import type b from './b' + import type c from './index' + import type d from 'd' + import type e from 'timers' + `, + }); + }); + + it("prioritizes index imports over sibling imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["index", "sibling"], + }, + ], + output: dedent` + import b from './index' + + import a from './a' + `, + code: dedent` + import a from './a' + + import b from './index' + `, + }); + }); + + it("prioritizes style side-effects over generic side-effects", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["side-effect-style", "side-effect"], + }, + ], + output: dedent` + import 'style.css' + + import 'something' + `, + code: dedent` + import 'something' + + import 'style.css' + `, + }); + }); + + it("prioritizes side-effects over style imports with default exports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["side-effect", "style"], + }, + ], + output: dedent` + import 'something' + + import style from 'style.css' + `, + code: dedent` + import style from 'style.css' + + import 'something' + `, + }); + }); + + it("prioritizes style imports over other import types", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + "style", + [ + "index", + "internal", + "subpath", + "external", + "sibling", + "builtin", + "parent", + "tsconfig-path", + ], + ], + tsconfigRootDir: ".", + }, + ], + output: dedent` + import style from 'style.css' + + import a from '../a' + import b from './b' + import c from './index' + import subpath from '#subpath' + import tsConfigPath from '$path' + import d from 'd' + import e from 'timers' + `, + code: dedent` + import a from '../a' + import b from './b' + import c from './index' + import subpath from '#subpath' + import tsConfigPath from '$path' + import d from 'd' + import e from 'timers' + + import style from 'style.css' + `, + before: () => { + mockReadClosestTsConfigByPathWith({ + paths: { + $path: ["./path"], + }, + }); + }, + }); + }); + + it("prioritizes external imports over generic import group", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["external", "import"], + }, + ], + output: dedent` + import b from 'b' + + import a from './a' + `, + code: dedent` + import a from './a' + + import b from 'b' + `, + }); + }); + + it("prioritizes type imports over TypeScript equals imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["type-import", "external", "ts-equals-import"], + }, + ], + output: dedent` + import type z = z + + import f from 'f' + `, + code: dedent` + import f from 'f' + + import type z = z + `, + }); + }); + + it("prioritizes side-effect imports over value imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["side-effect-import", "external", "value-import"], + sortSideEffects: true, + }, + ], + output: dedent` + import "./z" + + import f from 'f' + `, + code: dedent` + import f from 'f' + + import "./z" + `, + }); + }); + + it("prioritizes value imports over TypeScript equals imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["value-import", "external", "ts-equals-import"], + }, + ], + output: dedent` + import z = z + + import f from 'f' + `, + code: dedent` + import f from 'f' + + import z = z + `, + }); + }); + + it("prioritizes TypeScript equals imports over require imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["ts-equals-import", "external", "require-import"], + }, + ], + output: dedent` + import z = require('./z') + + import f from 'f' + `, + code: dedent` + import f from 'f' + + import z = require('./z') + `, + }); + }); + + it("prioritizes default imports over named imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["default-import", "external", "named-import"], + }, + ], + output: dedent` + import z, { z } from "./z" + + import f from 'f' + `, + code: dedent` + import f from 'f' + + import z, { z } from "./z" + `, + }); + }); + + it("prioritizes wildcard imports over named imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["wildcard-import", "external", "named-import"], + }, + ], + output: dedent` + import z, * as z from "./z" + + import f from 'f' + `, + code: dedent` + import f from 'f' + + import z, * as z from "./z" + `, + }); + }); + + it.each([ + ["filters on element name pattern with string", "hello"], + ["filters on element name pattern with array", ["noMatch", "hello"]], + [ + "filters on element name pattern with regex object", + { pattern: "HELLO", flags: "i" }, + ], + [ + "filters on element name pattern with array containing regex", + ["noMatch", { pattern: "HELLO", flags: "i" }], + ], + ])("%s", async (_description, elementNamePattern) => { + await invalid({ + options: [ + { + customGroups: [ + { + groupName: "importsStartingWithHello", + elementNamePattern, + }, + ], + groups: ["importsStartingWithHello", "unknown"], + }, + ], + output: dedent` + import hello from 'helloImport' + + import a from 'a' + `, + code: dedent` + import a from 'a' + + import hello from 'helloImport' + `, + }); + }); + + it("sorts custom groups by overriding type and order settings", async () => { + await invalid({ + options: [ + { + customGroups: [ + { + groupName: "reversedExternalImportsByLineLength", + selector: "external", + type: "line-length", + order: "desc", + }, + ], + groups: ["reversedExternalImportsByLineLength", "unknown"], + newlinesBetween: "ignore", + type: "alphabetical", + order: "asc", + }, + ], + output: dedent` + import dddd from 'dddd' + import ccc from 'ccc' + import eee from 'eee' + import bb from 'bb' + import ff from 'ff' + import a from 'a' + import g from 'g' + import h from './h' + import i from './i' + import jjjjj from './jjjjj' + `, + code: dedent` + import a from 'a' + import bb from 'bb' + import ccc from 'ccc' + import dddd from 'dddd' + import jjjjj from './jjjjj' + import eee from 'eee' + import ff from 'ff' + import g from 'g' + import h from './h' + import i from './i' + `, + }); + }); + + it("sorts custom groups using fallback sort settings", async () => { + await invalid({ + options: [ + { + customGroups: [ + { + fallbackSort: { + type: "alphabetical", + order: "asc", + }, + elementNamePattern: "^foo", + type: "line-length", + groupName: "foo", + order: "desc", + }, + ], + type: "alphabetical", + groups: ["foo"], + order: "asc", + }, + ], + output: dedent` + import fooBar from 'fooBar' + import fooZar from 'fooZar' + `, + code: dedent` + import fooZar from 'fooZar' + import fooBar from 'fooBar' + `, + }); + }); + + it("preserves order for custom groups with unsorted type", async () => { + await invalid({ + options: [ + { + customGroups: [ + { + groupName: "unsortedExternalImports", + selector: "external", + type: "unsorted", + }, + ], + groups: ["unsortedExternalImports", "unknown"], + newlinesBetween: "ignore", + }, + ], + output: dedent` + import b from 'b' + import a from 'a' + import d from 'd' + import e from 'e' + import c from 'c' + import something from './something' + `, + code: dedent` + import b from 'b' + import a from 'a' + import d from 'd' + import e from 'e' + import something from './something' + import c from 'c' + `, + }); + }); + + it("sorts custom group blocks with complex selectors", async () => { + await invalid({ + options: [ + { + customGroups: [ + { + anyOf: [ + { + selector: "external", + }, + { + selector: "sibling", + modifiers: ["type"], + }, + ], + groupName: "externalAndTypeSiblingImports", + }, + ], + groups: [["externalAndTypeSiblingImports", "index"], "unknown"], + newlinesBetween: "ignore", + }, + ], + output: dedent` + import type c from './c' + import type d from './d' + import i from './index' + import a from 'a' + import e from 'e' + import b from './b' + `, + code: dedent` + import a from 'a' + import b from './b' + import type c from './c' + import type d from './d' + import e from 'e' + import i from './index' + `, + }); + }); + + it.each([ + ["adds spacing inside custom groups when always option is used", "always"], + ["adds spacing inside custom groups when 1 option is used", 1], + ])("%s", async (_description, newlinesInside) => { + await invalid({ + options: [ + { + customGroups: [ + { + selector: "external", + groupName: "group1", + newlinesInside, + }, + ], + groups: ["group1"], + }, + ], + output: dedent` + import a from 'a' + + import b from 'b' + `, + code: dedent` + import a from 'a' + import b from 'b' + `, + }); + }); + + it.each([ + ["removes spacing inside custom groups when never option is used", "never"], + ["removes spacing inside custom groups when 0 option is used", 0], + ])("%s", async (_description, newlinesInside) => { + await invalid({ + options: [ + { + customGroups: [ + { + selector: "external", + groupName: "group1", + newlinesInside, + }, + ], + type: "alphabetical", + groups: ["group1"], + }, + ], + output: dedent` + import a from 'a' + import b from 'b' + `, + code: dedent` + import a from 'a' + + import b from 'b' + `, + }); + }); + + it("detects TypeScript import-equals dependencies", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["unknown"], + }, + ], + output: dedent` + import { aImport } from "b"; + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + import { aImport } from "b"; + `, + }); + + await invalid({ + options: [ + { + ...options, + groups: ["unknown"], + }, + ], + output: dedent` + import * as aImport from "b"; + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + import * as aImport from "b"; + `, + }); + + await invalid({ + options: [ + { + ...options, + groups: ["unknown"], + }, + ], + output: dedent` + import aImport from "b"; + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + import aImport from "b"; + `, + }); + + await invalid({ + options: [ + { + ...options, + groups: ["unknown"], + }, + ], + output: dedent` + import aImport = require("b") + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + import aImport = require("b") + `, + }); + }); + + it("prioritizes dependencies over group configuration", async () => { + await valid({ + options: [ + { + ...options, + customGroups: [ + { + groupName: "importsStartingWithA", + elementNamePattern: "^a", + }, + { + groupName: "importsStartingWithB", + elementNamePattern: "^b", + }, + ], + groups: ["importsStartingWithA", "importsStartingWithB"], + }, + ], + code: dedent` + import aImport from "b"; + import a = aImport.a1.a2; + `, + }); + + await invalid({ + options: [ + { + ...options, + customGroups: [ + { + groupName: "importsStartingWithA", + elementNamePattern: "^a", + }, + { + groupName: "importsStartingWithB", + elementNamePattern: "^b", + }, + ], + groups: ["importsStartingWithA", "importsStartingWithB"], + }, + ], + output: dedent` + import aImport from "b"; + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + import aImport from "b"; + `, + }); + }); + + it("prioritizes dependencies over comment-based partitions", async () => { + await invalid({ + output: dedent` + import aImport from "b"; + + // Part: 1 + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + + // Part: 1 + import aImport from "b"; + `, + options: [ + { + ...options, + partitionByComment: "^Part", + }, + ], + }); + }); + + it("prioritizes dependencies over newline-based partitions", async () => { + await invalid({ + options: [ + { + ...options, + newlinesBetween: "ignore", + partitionByNewLine: true, + }, + ], + output: dedent` + import aImport from "b"; + + import a = aImport.a1.a2; + `, + code: dedent` + import a = aImport.a1.a2; + + import aImport from "b"; + `, + }); + }); + + it("prioritizes content separation over dependencies", async () => { + await invalid({ + output: dedent` + import f = fImport.f1.f2; + + import yImport from "z"; + + import y = yImport.y1.y2; + + export { something } from "something"; + + import aImport from "b"; + + import a = aImport.a1.a2; + + import fImport from "g"; + `, + code: dedent` + import f = fImport.f1.f2; + + import y = yImport.y1.y2; + + import yImport from "z"; + + export { something } from "something"; + + import a = aImport.a1.a2; + + import aImport from "b"; + + import fImport from "g"; + `, + options: [ + { + ...options, + newlinesBetween: "ignore", + partitionByNewLine: true, + }, + ], + }); + }); + + it("treats @ symbol pattern as internal imports", async () => { + await invalid({ + options: [ + { + ...options, + groups: ["external", "internal"], + newlinesBetween: "always", + }, + ], + output: dedent` + import { b } from 'b' + + import { a } from '@/a' + `, + code: dedent` + import { b } from 'b' + import { a } from '@/a' + `, + }); + }); + + it("reports missing comments above import groups", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + { commentAbove: "Comment above a" }, + "external", + { commentAbove: "Comment above b" }, + "unknown", + ], + }, + ], + output: dedent` + // Comment above a + import { a } from "a"; + + // Comment above b + import { b } from "./b"; + `, + code: dedent` + import { a } from "a"; + + import { b } from "./b"; + `, + }); + }); + + it("reports missing comments for single import groups", async () => { + await invalid({ + options: [ + { + ...options, + groups: [{ commentAbove: "Comment above" }, "unknown"], + }, + ], + output: dedent` + // Comment above + import { a } from "a"; + `, + code: dedent` + import { a } from "a"; + `, + }); + }); + + it("ignores shebangs and top-level comments when adding group comments", async () => { + await invalid({ + output: dedent` + #!/usr/bin/node + // Some disclaimer + + // Comment above + import a from "a"; + import b from "b"; + `, + options: [ + { + ...options, + groups: [{ commentAbove: "Comment above" }, "external"], + }, + ], + code: dedent` + #!/usr/bin/node + // Some disclaimer + + import b from "b"; + import a from "a"; + `, + }); + }); + + it.each([ + ["detects existing line comment with extra spaces", "// Comment above "], + [ + "detects existing line comment with different case", + "// comment above ", + ], + [ + "detects existing block comment with standard format", + dedent` + /** + * Comment above + */ + `, + ], + [ + "detects existing block comment with surrounding text", + dedent` + /** + * Something before + * CoMmEnT ABoVe + * Something after + */ + `, + ], + ])("%s", async (_description, comment) => { + await valid({ + options: [ + { + ...options, + groups: ["external", { commentAbove: "Comment above" }, "unknown"], + }, + ], + code: dedent` + import a from "a"; + + ${comment} + import b from "./b"; + `, + }); + }); + + it("removes and repositions invalid auto-added comments", async () => { + await invalid({ + options: [ + { + ...options, + groups: [ + { commentAbove: "external" }, + "external", + { commentAbove: "sibling" }, + "sibling", + { commentAbove: "internal" }, + "internal", + ], + }, + ], + output: dedent` + // external + import a from "a"; + + // sibling + import b from './b'; + + // internal + import c from '~/c'; + import d from '~/d'; + `, + code: dedent` + import d from '~/d'; + // internal + import c from '~/c'; + + // sibling + import b from './b'; + + // external + import a from "a"; + `, + }); + }); + + it("handles complex scenarios with multiple error types and comment management", async () => { + await invalid({ + code: dedent` + #!/usr/bin/node + // Some disclaimer + + // Comment above c + // external + import c from './c'; // Comment after c + // Comment above a + // internal or sibling + import a from "a"; // Comment after a + // Comment above b + // external + import b from '~/b'; // Comment after b + `, + options: [ + { + ...options, + groups: [ + { commentAbove: "external" }, + "external", + { + commentAbove: "internal or sibling", + newlinesBetween: "always", + }, + ["internal", "sibling"], + ], + newlinesBetween: "never", + }, + ], + output: dedent` + #!/usr/bin/node + // Some disclaimer + + // Comment above a + // external + import a from "a"; // Comment after a + + // internal or sibling + // Comment above c + import c from './c'; // Comment after c + // Comment above b + import b from '~/b'; // Comment after b + `, + }); + }); +}); + +describe("unsorted", () => { + let options = { + type: "unsorted", + order: "asc", + } as const; + + it("preserves original order when sorting is disabled", async () => { + await valid({ + code: dedent` + import { b } from 'b'; + import { c } from 'c'; + import { a } from 'a'; + `, + options: [options], + }); + }); + + it("enforces group order regardless of sorting settings", async () => { + await invalid({ + options: [ + { + ...options, + customGroups: { + value: { + a: "^a", + b: "^b", + }, + }, + groups: ["b", "a"], + }, + ], + output: dedent` + import { ba } from 'ba' + import { bb } from 'bb' + + import { ab } from 'ab' + import { aa } from 'aa' + `, + code: dedent` + import { ab } from 'ab' + import { aa } from 'aa' + import { ba } from 'ba' + import { bb } from 'bb' + `, + }); + }); + + it("enforces spacing rules between import groups", async () => { + await invalid({ + options: [ + { + ...options, + customGroups: { + value: { + a: "^a", + b: "^b", + }, + }, + newlinesBetween: "never", + groups: ["b", "a"], + }, + ], + errors: [ + { + data: { + right: "a", + left: "b", + }, + messageId: "extraSpacingBetweenImports", + }, + ], + output: dedent` + import { b } from 'b' + import { a } from 'a' + `, + code: dedent` + import { b } from 'b' + + import { a } from 'a' + `, + }); + }); +}); + +describe("misc", () => { + it("supports combination of predefined and custom groups", async () => { + await valid({ + options: [ + { + groups: [ + "side-effect-style", + "external-type", + "internal-type", + "builtin-type", + "sibling-type", + "parent-type", + "side-effect", + "index-type", + "internal", + "external", + "sibling", + "unknown", + "builtin", + "parent", + "index", + "style", + "type", + "myCustomGroup1", + ], + customGroups: { + type: { + myCustomGroup1: "x", + }, + }, + }, + ], + code: dedent` + import type { T } from 't' + + // @ts-expect-error missing types + import { t } from 't' + `, + }); + }); + + it("preserves order of side-effect imports", async () => { + await valid(dedent` + import './index.css' + import './animate.css' + import './reset.css' + `); + }); + + it("recognizes Node.js built-in modules with node: prefix", async () => { + await valid({ + code: dedent` + import { writeFile } from 'node:fs/promises' + + import { useEffect } from 'react' + `, + options: [ + { + groups: ["builtin", "external"], + }, + ], + }); + + await invalid({ + output: dedent` + import { writeFile } from 'node:fs/promises' + + import { useEffect } from 'react' + `, + code: dedent` + import { writeFile } from 'node:fs/promises' + import { useEffect } from 'react' + `, + options: [ + { + groups: ["builtin", "external"], + }, + ], + }); + }); + + it("classifies internal pattern side-effects correctly by group priority", async () => { + await valid({ + code: dedent` + import { useClient } from '~/hooks/useClient' + + import '~/css/globals.css' + + import '~/data' + `, + options: [ + { + groups: ["internal", "side-effect-style", "side-effect"], + }, + ], + }); + + await invalid({ + output: dedent` + import { useClient } from '~/hooks/useClient' + + import '~/css/globals.css' + + import '~/data' + `, + code: dedent` + import { useClient } from '~/hooks/useClient' + import '~/data' + import '~/css/globals.css' + `, + options: [ + { + groups: ["internal", "side-effect-style", "side-effect"], + }, + ], + }); + }); + + it("handles complex projects with many custom groups", async () => { + await valid({ + options: [ + { + customGroups: { + value: { + validators: ["^~/validators/.+"], + composable: ["^~/composable/.+"], + components: ["^~/components/.+"], + services: ["^~/services/.+"], + widgets: ["^~/widgets/.+"], + stores: ["^~/stores/.+"], + logics: ["^~/logics/.+"], + assets: ["^~/assets/.+"], + utils: ["^~/utils/.+"], + pages: ["^~/pages/.+"], + ui: ["^~/ui/.+"], + }, + }, + groups: [ + ["builtin", "external"], + "internal", + "stores", + "services", + "validators", + "utils", + "logics", + "composable", + "ui", + "components", + "pages", + "widgets", + "assets", + "parent", + "sibling", + "side-effect", + "index", + "style", + "unknown", + ], + type: "line-length", + }, + ], + code: dedent` + import { useCartStore } from '~/stores/cartStore.ts' + import { useUserStore } from '~/stores/userStore.ts' + + import { getCart } from '~/services/cartService.ts' + + import { connect } from '~/utils/ws.ts' + import { formattingDate } from '~/utils/dateTime.ts' + + import { useFetch } from '~/composable/useFetch.ts' + import { useDebounce } from '~/composable/useDebounce.ts' + import { useMouseMove } from '~/composable/useMouseMove.ts' + + import ComponentA from '~/components/ComponentA.vue' + import ComponentB from '~/components/ComponentB.vue' + import ComponentC from '~/components/ComponentC.vue' + + import CartComponentA from './cart/CartComponentA.vue' + import CartComponentB from './cart/CartComponentB.vue' + `, + }); + + await invalid({ + options: [ + { + customGroups: { + value: { + validators: ["~/validators/.+"], + composable: ["~/composable/.+"], + components: ["~/components/.+"], + services: ["~/services/.+"], + widgets: ["~/widgets/.+"], + stores: ["~/stores/.+"], + logics: ["~/logics/.+"], + assets: ["~/assets/.+"], + utils: ["~/utils/.+"], + pages: ["~/pages/.+"], + ui: ["~/ui/.+"], + }, + }, + groups: [ + ["builtin", "external"], + "internal", + "stores", + "services", + "validators", + "utils", + "logics", + "composable", + "ui", + "components", + "pages", + "widgets", + "assets", + "parent", + "sibling", + "side-effect", + "index", + "style", + "unknown", + ], + type: "line-length", + }, + ], + output: dedent` + import { useUserStore } from '~/stores/userStore.ts' + import { useCartStore } from '~/stores/cartStore.ts' + + import { getCart } from '~/services/cartService.ts' + + import { connect } from '~/utils/ws.ts' + import { formattingDate } from '~/utils/dateTime.ts' + + import { useFetch } from '~/composable/useFetch.ts' + import { useDebounce } from '~/composable/useDebounce.ts' + import { useMouseMove } from '~/composable/useMouseMove.ts' + + import ComponentA from '~/components/ComponentA.vue' + import ComponentB from '~/components/ComponentB.vue' + import ComponentC from '~/components/ComponentC.vue' + + import CartComponentA from './cart/CartComponentA.vue' + import CartComponentB from './cart/CartComponentB.vue' + `, + code: dedent` + import CartComponentA from './cart/CartComponentA.vue' + import CartComponentB from './cart/CartComponentB.vue' + + import { connect } from '~/utils/ws.ts' + import { getCart } from '~/services/cartService.ts' + + import { useUserStore } from '~/stores/userStore.ts' + import { formattingDate } from '~/utils/dateTime.ts' + + import { useFetch } from '~/composable/useFetch.ts' + import { useCartStore } from '~/stores/cartStore.ts' + import { useDebounce } from '~/composable/useDebounce.ts' + import { useMouseMove } from '~/composable/useMouseMove.ts' + + import ComponentA from '~/components/ComponentA.vue' + import ComponentB from '~/components/ComponentB.vue' + import ComponentC from '~/components/ComponentC.vue' + `, + }); + }); + + it("treats empty named imports as regular imports not side-effects", async () => { + await valid({ + code: dedent` + import {} from 'node:os' + import sqlite from 'node:sqlite' + import { describe, test } from 'node:test' + import { c } from 'c' + import 'node:os' + `, + options: [ + { + groups: ["builtin", "external", "side-effect"], + newlinesBetween: "never", + }, + ], + }); + }); + + it("ignores dynamic require statements", async () => { + await valid({ + code: dedent` + const path = require(path); + const myFileName = require('the-filename'); + const file = require(path.join(myDir, myFileName)); + const other = require('./other.js'); + `, + options: [ + { + groups: ["builtin", "external", "side-effect"], + newlinesBetween: "never", + }, + ], + }); + }); + + describe("validates compatibility between sortSideEffects and groups configuration", () => { + function createRule( + groups: Options[0]["groups"], + sortSideEffects: boolean = false, + ): RuleListener { + return rule.create({ + options: [ + { + sortSideEffects, + groups, + }, + ], + } as Readonly>); + } + + let expectedThrownError = + "Side effect groups cannot be nested with non side effect groups when 'sortSideEffects' is 'false'."; + + it("throws error when side-effect group is nested with non-side-effect groups", () => { + expect(() => + createRule(["external", ["side-effect", "internal"]]), + ).toThrow(expectedThrownError); + }); + + it("throws error when side-effect-style group is nested with non-side-effect groups", () => { + expect(() => + createRule(["external", ["side-effect-style", "internal"]]), + ).toThrow(expectedThrownError); + }); + + it("throws error when mixed side-effect groups are nested with non-side-effect groups", () => { + expect(() => + createRule([ + "external", + ["side-effect-style", "internal", "side-effect"], + ]), + ).toThrow(expectedThrownError); + }); + + it("allows side-effect groups to be nested together", () => { + expect(() => + createRule(["external", ["side-effect-style", "side-effect"]]), + ).not.toThrow(expectedThrownError); + }); + + it("allows any group nesting when sortSideEffects is enabled", () => { + expect(() => + createRule( + ["external", ["side-effect-style", "internal", "side-effect"]], + true, + ), + ).not.toThrow(expectedThrownError); + }); + }); + + it("classifies TypeScript configured imports as internal", async () => { + await valid({ + options: [ + { + groups: ["internal", "unknown"], + tsconfigRootDir: ".", + }, + ], + before: () => { + mockReadClosestTsConfigByPathWith({ + baseUrl: "./rules/", + }); + }, + code: dedent` + import { x } from 'sort-imports' + + import { a } from './a'; + `, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("classifies package imports as external", async () => { + await valid({ + options: [ + { + groups: ["external", "unknown"], + tsconfigRootDir: ".", + }, + ], + code: dedent` + import type { ParsedCommandLine } from 'typescript' + + import { a } from './a'; + `, + before: () => { + mockReadClosestTsConfigByPathWith({ + baseUrl: ".", + }); + }, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("treats unresolved imports as external by default", async () => { + await valid({ + options: [ + { + groups: ["external", "unknown"], + tsconfigRootDir: ".", + }, + ], + before: () => { + mockReadClosestTsConfigByPathWith({ + baseUrl: ".", + }); + }, + code: dedent` + import { b } from 'b' + + import { a } from './a'; + `, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("falls back to basic classification when TypeScript is unavailable", async () => { + await valid({ + before: () => { + vi.spyOn( + getTypescriptImportUtilities, + "getTypescriptImport", + ).mockReturnValue(null); + }, + options: [ + { + groups: ["external", "unknown"], + tsconfigRootDir: ".", + }, + ], + code: dedent` + import { b } from 'b' + + import { a } from './a'; + `, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("classifies TypeScript configured imports as internal with tsconfig option", async () => { + await valid({ + options: [ + { + tsconfig: { + filename: "tsconfig.json", + rootDir: ".", + }, + groups: ["internal", "unknown"], + }, + ], + before: () => { + mockReadClosestTsConfigByPathWith({ + baseUrl: "./rules/", + }); + }, + code: dedent` + import { x } from 'sort-imports' + + import { a } from './a'; + `, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("classifies package imports as external with tsconfig option", async () => { + await valid({ + options: [ + { + tsconfig: { + filename: "tsconfig.json", + rootDir: ".", + }, + groups: ["external", "unknown"], + }, + ], + code: dedent` + import type { ParsedCommandLine } from 'typescript' + + import { a } from './a'; + `, + before: () => { + mockReadClosestTsConfigByPathWith({ + baseUrl: ".", + }); + }, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("treats unresolved imports as external by default with tsconfig option", async () => { + await valid({ + options: [ + { + tsconfig: { + filename: "tsconfig.json", + rootDir: ".", + }, + groups: ["external", "unknown"], + }, + ], + before: () => { + mockReadClosestTsConfigByPathWith({ + baseUrl: ".", + }); + }, + code: dedent` + import { b } from 'b' + + import { a } from './a'; + `, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("falls back to basic classification when TypeScript is unavailable with tsconfig option", async () => { + await valid({ + options: [ + { + tsconfig: { + filename: "tsconfig.json", + rootDir: ".", + }, + groups: ["external", "unknown"], + }, + ], + before: () => { + vi.spyOn( + getTypescriptImportUtilities, + "getTypescriptImport", + ).mockReturnValue(null); + }, + code: dedent` + import { b } from 'b' + + import { a } from './a'; + `, + after: () => { + vi.resetAllMocks(); + }, + }); + }); + + it("respects ESLint disable comments when sorting imports", async () => { + await valid({ + code: dedent` + import { b } from "./b" + import { c } from "./c" + // eslint-disable-next-line + import { a } from "./a" + `, + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + // eslint-disable-next-line + import { a } from './a' + `, + code: dedent` + import { c } from './c' + import { b } from './b' + // eslint-disable-next-line + import { a } from './a' + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + // eslint-disable-next-line + import { a } from './a' + import { d } from './d' + `, + code: dedent` + import { d } from './d' + import { c } from './c' + // eslint-disable-next-line + import { a } from './a' + import { b } from './b' + `, + options: [ + { + partitionByComment: true, + }, + ], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + import { a } from './a' // eslint-disable-line + `, + code: dedent` + import { c } from './c' + import { b } from './b' + import { a } from './a' // eslint-disable-line + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + /* eslint-disable-next-line */ + import { a } from './a' + `, + code: dedent` + import { c } from './c' + import { b } from './b' + /* eslint-disable-next-line */ + import { a } from './a' + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + import { a } from './a' /* eslint-disable-line */ + `, + code: dedent` + import { c } from './c' + import { b } from './b' + import { a } from './a' /* eslint-disable-line */ + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { a } from './a' + import { d } from './d' + /* eslint-disable */ + import { c } from './c' + import { b } from './b' + // Shouldn't move + /* eslint-enable */ + import { e } from './e' + `, + code: dedent` + import { d } from './d' + import { e } from './e' + /* eslint-disable */ + import { c } from './c' + import { b } from './b' + // Shouldn't move + /* eslint-enable */ + import { a } from './a' + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + // eslint-disable-next-line rule-to-test/sort-imports + import { a } from './a' + `, + code: dedent` + import { c } from './c' + import { b } from './b' + // eslint-disable-next-line rule-to-test/sort-imports + import { a } from './a' + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + import { a } from './a' // eslint-disable-line rule-to-test/sort-imports + `, + code: dedent` + import { c } from './c' + import { b } from './b' + import { a } from './a' // eslint-disable-line rule-to-test/sort-imports + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + /* eslint-disable-next-line rule-to-test/sort-imports */ + import { a } from './a' + `, + code: dedent` + import { c } from './c' + import { b } from './b' + /* eslint-disable-next-line rule-to-test/sort-imports */ + import { a } from './a' + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { b } from './b' + import { c } from './c' + import { a } from './a' /* eslint-disable-line rule-to-test/sort-imports */ + `, + code: dedent` + import { c } from './c' + import { b } from './b' + import { a } from './a' /* eslint-disable-line rule-to-test/sort-imports */ + `, + options: [{}], + }); + + await invalid({ + output: dedent` + import { a } from './a' + import { d } from './d' + /* eslint-disable rule-to-test/sort-imports */ + import { c } from './c' + import { b } from './b' + // Shouldn't move + /* eslint-enable */ + import { e } from './e' + `, + code: dedent` + import { d } from './d' + import { e } from './e' + /* eslint-disable rule-to-test/sort-imports */ + import { c } from './c' + import { b } from './b' + // Shouldn't move + /* eslint-enable */ + import { a } from './a' + `, + options: [{}], + }); + }); +}); diff --git a/crates/oxc_formatter/tests/ir_transform/mod.rs b/crates/oxc_formatter/tests/ir_transform/mod.rs new file mode 100644 index 0000000000000..8ff4f1df8e94c --- /dev/null +++ b/crates/oxc_formatter/tests/ir_transform/mod.rs @@ -0,0 +1,65 @@ +mod sort_imports; + +use oxc_formatter::FormatOptions; + +pub fn assert_format(code: &str, options: &FormatOptions, expected: &str) { + // NOTE: Strip leading single `\n` for better test case readability. + let code = code.strip_prefix('\n').expect("Test code should start with a newline"); + let expected = expected.strip_prefix('\n').expect("Expected code should start with a newline"); + + let actual = format_code(code, options); + assert_eq!( + actual, expected, + r" +💥 First format does not match expected! +============== actual ============= +{actual} +============= expected ============ +{expected} +============== options ============ +{options} +" + ); + + // Check idempotency + let actual = format_code(&actual, options); + assert_eq!( + actual, expected, + r" +💥 Formatting is not idempotent! +============== actual ============= +{actual} +============= expected ============ +{expected} +============== options ============ +{options} +" + ); +} + +fn format_code(code: &str, options: &FormatOptions) -> String { + use oxc_allocator::Allocator; + use oxc_formatter::Formatter; + use oxc_parser::{ParseOptions, Parser}; + use oxc_span::SourceType; + + let allocator = Allocator::new(); + let source_type = SourceType::from_path("dummy.tsx").unwrap(); + + let ret = Parser::new(&allocator, code, source_type) + .with_options(ParseOptions { + parse_regular_expression: false, + // Enable all syntax features + allow_v8_intrinsics: true, + allow_return_outside_function: true, + // `oxc_formatter` expects this to be false + preserve_parens: false, + }) + .parse(); + + if let Some(error) = ret.errors.first() { + panic!("💥 Parser error: {}", error.message); + } + + Formatter::new(&allocator, options.clone()).build(&ret.program) +} diff --git a/crates/oxc_formatter/tests/ir_transform/sort_imports.rs b/crates/oxc_formatter/tests/ir_transform/sort_imports.rs new file mode 100644 index 0000000000000..73ec4e751400c --- /dev/null +++ b/crates/oxc_formatter/tests/ir_transform/sort_imports.rs @@ -0,0 +1,659 @@ +use super::assert_format; +use oxc_formatter::{FormatOptions, QuoteStyle, Semicolons, SortImports}; + +#[test] +fn should_not_sort_by_default() { + assert_format( + r#" +import { b1, type b2, b3 as b33 } from "b"; +import * as c from "c"; +import type d from "d"; +import a from "a"; +"#, + &FormatOptions { experimental_sort_imports: None, ..Default::default() }, + r#" +import { b1, type b2, b3 as b33 } from "b"; +import * as c from "c"; +import type d from "d"; +import a from "a"; +"#, + ); +} + +// --- + +#[test] +fn should_sort() { + assert_format( + r#" +import { b1, type b2, b3 as b33 } from "b"; +import * as c from "c"; +import type d from "d"; +import a from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import a from "a"; +import { b1, type b2, b3 as b33 } from "b"; +import * as c from "c"; +import type d from "d"; +"#, + ); + // Alphabetical ASC order by default + assert_format( + r#" +import { log } from "./log"; +import { log10 } from "./log10"; +import { log1p } from "./log1p"; +import { log2 } from "./log2"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import { log } from "./log"; +import { log10 } from "./log10"; +import { log1p } from "./log1p"; +import { log2 } from "./log2"; +"#, + ); + // Dynamic imports should not affect sorting + assert_format( + r#" +import c from "c"; +import b from "b"; +import("a"); +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import b from "b"; +import c from "c"; +import("a"); +"#, + ); +} + +#[test] +fn should_handle_shebang() { + assert_format( + r#" +#!/usr/bin/node +// b +import { b } from "b"; +// a +import { a } from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +#!/usr/bin/node +// a +import { a } from "a"; +// b +import { b } from "b"; +"#, + ); +} + +#[test] +fn should_handle_single_import() { + assert_format( + r#" +import A from "a"; + +console.log(A); +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import A from "a"; + +console.log(A); +"#, + ); +} + +#[test] +fn should_handle_same_source_imports() { + assert_format( + r#" +import { z } from "a"; +import { y } from "a"; +import { x } from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import { z } from "a"; +import { y } from "a"; +import { x } from "a"; +"#, + ); +} + +#[test] +fn should_sort_regardless_of_quotes() { + assert_format( + r#" +import b from "b"; +import a from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import a from "a"; +import b from "b"; +"#, + ); + // Change quote style + assert_format( + r#" +import b from "b"; +import a from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + quote_style: QuoteStyle::Single, + ..Default::default() + }, + r" +import a from 'a'; +import b from 'b'; +", + ); +} + +#[test] +fn should_sort_by_module_source_not_import_specifier() { + // Ensure sorting uses the module path after "from", not the import specifier + assert_format( + r#" +import { Zoo } from "aaa"; +import { Apple } from "zzz"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import { Zoo } from "aaa"; +import { Apple } from "zzz"; +"#, + ); + // Named imports with similar specifier names but different paths + assert_format( + r#" +import { Named } from "./z-path"; +import { Named } from "./a-path"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import { Named } from "./a-path"; +import { Named } from "./z-path"; +"#, + ); + // Multiple specifiers - should sort by path not by first specifier + assert_format( + r#" +import { AAA, BBB, CCC } from "./zzz"; +import { XXX, YYY, ZZZ } from "./aaa"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import { XXX, YYY, ZZZ } from "./aaa"; +import { AAA, BBB, CCC } from "./zzz"; +"#, + ); +} + +#[test] +fn should_support_style_imports_with_query() { + assert_format( + r#" +import b from "./b.css?raw"; +import a from "./a.css?"; +import c from "./c.css"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import a from "./a.css?"; +import b from "./b.css?raw"; +import c from "./c.css"; +"#, + ); +} + +#[test] +fn should_remove_newlines_only_between_import_chunks() { + assert_format( + r#" +import d from "~/d"; + +import c from "~/c"; + + +import b from "~/b"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import b from "~/b"; +import c from "~/c"; +import d from "~/d"; +"#, + ); + // Newlines are removed, but comments are preserved + assert_format( + r#" +import d from "./d"; // D +// c1 +// c2 +import c from "./c"; // C +// b +import b from "./b"; // B +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +// b +import b from "./b"; // B +// c1 +// c2 +import c from "./c"; // C +import d from "./d"; // D +"#, + ); + // Between imports and other code, newlines are preserved + assert_format( + r#" +import x2 from "./x2"; +import x1 from "./x1"; +// Empty line below should be preserved + +const a = 1; + +// These are preserved too + +const b = 2; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import x1 from "./x1"; +import x2 from "./x2"; +// Empty line below should be preserved + +const a = 1; + +// These are preserved too + +const b = 2; +"#, + ); +} + +#[test] +fn should_preserve_inline_comments_during_sorting() { + assert_format( + r#" +import { a } from "a"; +import { b1, b2 } from "b"; // Comment +import { c } from "c"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import { a } from "a"; +import { b1, b2 } from "b"; // Comment +import { c } from "c"; +"#, + ); +} + +#[test] +fn should_stop_grouping_when_other_statements_appear() { + assert_format( + r#" +import type { V } from "v"; +export type { U } from "u"; +import type { T1, T2 } from "t"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import type { V } from "v"; +export type { U } from "u"; +import type { T1, T2 } from "t"; +"#, + ); + // Every line other than import lines should break the grouping + assert_format( + r#" +import type { V } from "v"; +const X = 1; +import type { T1, T2 } from "t"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports::default()), + ..Default::default() + }, + r#" +import type { V } from "v"; +const X = 1; +import type { T1, T2 } from "t"; +"#, + ); +} + +// --- + +#[test] +fn should_partition_by_newlines() { + assert_format( + r#" +import * as atoms from "./atoms"; +import * as organisms from "./organisms"; +import * as shared from "./shared"; + +import { Named } from './folder'; +import { AnotherNamed } from './second-folder'; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import * as atoms from "./atoms"; +import * as organisms from "./organisms"; +import * as shared from "./shared"; + +import { Named } from "./folder"; +import { AnotherNamed } from "./second-folder"; +"#, + ); + // Extra newlines are already removed before sorting + assert_format( + r#" +import * as atoms from "./atoms"; +import * as organisms from "./organisms"; +import * as shared from "./shared"; + + +import { Named } from './folder'; +import { AnotherNamed } from './second-folder'; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import * as atoms from "./atoms"; +import * as organisms from "./organisms"; +import * as shared from "./shared"; + +import { Named } from "./folder"; +import { AnotherNamed } from "./second-folder"; +"#, + ); + // More partitions + assert_format( + r#" +import D from "d"; + +import C from "c"; + +import B from "b"; + +import A from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import D from "d"; + +import C from "c"; + +import B from "b"; + +import A from "a"; +"#, + ); + // Ensure comments adjacent to imports stay with their import + assert_format( + r#" +import Y from "y"; +// Comment for X +import X from "x"; + +import B from "b"; +// Comment for A +import A from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +// Comment for X +import X from "x"; +import Y from "y"; + +// Comment for A +import A from "a"; +import B from "b"; +"#, + ); +} + +#[test] +fn should_partition_by_comment() { + assert_format( + r#" +import Y from "y"; +import X from "x"; +// PARTITION +import B from "b"; +import A from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_comment: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import X from "x"; +import Y from "y"; +// PARTITION +import A from "a"; +import B from "b"; +"#, + ); + // Ensure comments with different styles are recognized + assert_format( + r" +/* Partition Comment */ +// Part: A +import d from './d' +// Part: B +import aaa from './aaa' +import c from './c' +import bb from './bb' +/* Other */ +import e from './e' +", + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_comment: true, + ..Default::default() + }), + quote_style: QuoteStyle::Single, + semicolons: Semicolons::AsNeeded, + ..Default::default() + }, + r" +/* Partition Comment */ +// Part: A +import d from './d' +// Part: B +import aaa from './aaa' +import bb from './bb' +import c from './c' +/* Other */ +import e from './e' +", + ); + // Multiple comment lines + assert_format( + r#" +import C from "c"; +// Comment 1 +// Comment 2 +import B from "b"; +import A from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_comment: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import C from "c"; +// Comment 1 +// Comment 2 +import A from "a"; +import B from "b"; +"#, + ); +} + +#[test] +fn should_partition_by_both_newlines_and_comments() { + assert_format( + r#" +import X from "x"; + +import Z from "z"; +// PARTITION +import C from "c"; + +import B from "b"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + partition_by_comment: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import X from "x"; + +import Z from "z"; +// PARTITION +import C from "c"; + +import B from "b"; +"#, + ); + assert_format( + r#" +import C from "c"; + +// Comment +import B from "b"; +import A from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + partition_by_comment: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import C from "c"; + +// Comment +import A from "a"; +import B from "b"; +"#, + ); + assert_format( + r#" +import C from "c"; +// Comment + +import B from "b"; +import A from "a"; +"#, + &FormatOptions { + experimental_sort_imports: Some(SortImports { + partition_by_newline: true, + partition_by_comment: true, + ..Default::default() + }), + ..Default::default() + }, + r#" +import C from "c"; +// Comment + +import A from "a"; +import B from "b"; +"#, + ); +} diff --git a/crates/oxc_formatter/tests/mod.rs b/crates/oxc_formatter/tests/mod.rs new file mode 100644 index 0000000000000..4580a75c89ad3 --- /dev/null +++ b/crates/oxc_formatter/tests/mod.rs @@ -0,0 +1 @@ +mod ir_transform;