/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Recipe} from "../../recipe";
import {produceAsync, TreeVisitor} from "../../visitor";
import {ExecutionContext} from "../../execution";
import {JavaScriptVisitor, JS} from "../index";
import {J} from "../../java";
import {create as produce, Draft} from "mutative";
import {SpacesStyle, styleFromSourceFile, StyleKind} from "../style";
/**
* Import type categories for sorting order:
* 1. Side-effect imports (no specifier): import 'module';
* 2. Namespace imports: import * as foo from 'module';
* 3. Default imports: import foo from 'module';
* 4. Named imports: import { foo } from 'module';
* 5. Type imports: import type { Foo } from 'module';
*/
enum ImportCategory {
SideEffect = 0,
Namespace = 1,
Default = 2,
Named = 3,
Type = 4
}
export class OrderImports extends Recipe {
readonly name = "org.openrewrite.javascript.cleanup.order-imports";
readonly displayName = "Order imports";
readonly description = "Sort imports by category and module path. Categories: side-effect, namespace, default, named, type. Within each category, imports are sorted alphabetically by module path. Named specifiers within each import are also sorted alphabetically.";
async editor(): Promise> {
return new class extends JavaScriptVisitor {
protected async visitJsCompilationUnit(cu: JS.CompilationUnit, p: ExecutionContext): Promise {
const importCount = this.countImports(cu);
if (importCount === 0) {
return cu;
}
const imports = cu.statements.slice(0, importCount) as J.RightPadded[];
const originalImportPosition = Object.fromEntries(imports.map((item, i) => [item.element.id, i]));
const restStatements = cu.statements.slice(importCount);
// Get style for consistent brace spacing
const spacesStyle = styleFromSourceFile(StyleKind.SpacesStyle, cu) as SpacesStyle | undefined;
const useBraceSpaces = spacesStyle?.within.es6ImportExportBraces ?? false;
// Sort named specifiers within each import
const sortedSpecifiers = this.sortNamedSpecifiersWithinImports(imports, useBraceSpaces);
// Sort imports by category and module path
sortedSpecifiers.sort((aPadded, bPadded) => {
const a = aPadded.element;
const b = bPadded.element;
// First, compare by category
const categoryA = this.getImportCategory(a);
const categoryB = this.getImportCategory(b);
if (categoryA !== categoryB) {
return categoryA - categoryB;
}
// Within same category, sort by module path (case-insensitive)
const modulePathA = this.getModulePath(a).toLowerCase();
const modulePathB = this.getModulePath(b).toLowerCase();
const pathComparison = modulePathA.localeCompare(modulePathB);
if (pathComparison !== 0) {
return pathComparison;
}
// Tiebreaker: keep original order for stability
return originalImportPosition[aPadded.element.id] - originalImportPosition[bPadded.element.id];
});
// Check if anything actually changed
const alreadySorted = sortedSpecifiers.every((s, i) => s === imports[i]);
if (alreadySorted) {
return cu;
}
const cuWithImportsSorted = await produceAsync(cu, async draft => {
draft.statements = [...sortedSpecifiers, ...restStatements];
});
return produce(cuWithImportsSorted!, draft => {
for (let i = 0; i < importCount; i++) {
draft.statements[i].element.prefix.whitespace = i > 0 ? "\n" : "";
}
});
}
/**
* Determine the category of an import for sorting purposes.
*/
private getImportCategory(import_: JS.Import): ImportCategory {
// Type imports: import type { Foo } from 'module'
if (import_.importClause?.typeOnly) {
return ImportCategory.Type;
}
// Side-effect imports: import 'module'
if (import_.importClause === undefined) {
return ImportCategory.SideEffect;
}
// Namespace imports: import * as foo from 'module'
if (import_.importClause.namedBindings?.kind === JS.Kind.Alias) {
const alias = import_.importClause.namedBindings as JS.Alias;
if (alias.propertyName.element.simpleName === "*") {
return ImportCategory.Namespace;
}
}
// Default imports (without named imports): import foo from 'module'
if (import_.importClause.name && !import_.importClause.namedBindings) {
return ImportCategory.Default;
}
// Default with named imports or just named imports: import foo, { bar } from 'module' or import { foo } from 'module'
return ImportCategory.Named;
}
/**
* Extract the module path from an import statement.
*/
private getModulePath(import_: JS.Import): string {
if (import_.moduleSpecifier?.element.kind === J.Kind.Literal) {
const literal = import_.moduleSpecifier.element as J.Literal;
// Remove quotes from the value
return String(literal.value ?? '');
}
return '';
}
private countImports(cu: JS.CompilationUnit): number {
let i = 0;
while ((i < cu.statements.length) && (cu.statements[i].element.kind === JS.Kind.Import)) {
i++;
}
return i;
}
/**
* Sort named specifiers within each import statement alphabetically.
*/
private sortNamedSpecifiersWithinImports(imports: J.RightPadded[], useBraceSpaces: boolean): J.RightPadded[] {
const ret = [];
for (const importPadded of imports) {
const import_ = importPadded.element;
if (this.hasNamedImports(import_)) {
const importSorted = produce(import_, draft => {
const namedBindings = draft.importClause!.namedBindings as Draft;
let elements = namedBindings.elements.elements;
if (elements.length <= 1) {
return; // Nothing to sort
}
// Handle trailing comma
const trailingComma = elements.length > 0 &&
elements[elements.length - 1].markers?.markers.find(m => m.kind === J.Markers.TrailingComma);
if (trailingComma) {
elements[elements.length - 1].markers.markers =
elements[elements.length - 1].markers.markers.filter(m => m.kind !== J.Markers.TrailingComma);
}
// Sort by the imported name (not alias)
elements.sort((a, b) => {
const nameA = this.getSpecifierSortKey(a.element as JS.ImportSpecifier);
const nameB = this.getSpecifierSortKey(b.element as JS.ImportSpecifier);
return nameA.localeCompare(nameB);
});
// Normalize spacing based on es6ImportExportBraces style
const braceSpace = useBraceSpaces ? " " : "";
for (let i = 0; i < elements.length; i++) {
if (i === 0) {
// First element: space after opening brace based on style
elements[i].element.prefix = {kind: J.Kind.Space, whitespace: braceSpace, comments: []};
} else {
// Other elements: space after comma
elements[i].element.prefix = {kind: J.Kind.Space, whitespace: ' ', comments: []};
}
}
// Last element: space before closing brace based on style
elements[elements.length - 1].after = {kind: J.Kind.Space, whitespace: braceSpace, comments: []};
// Restore trailing comma to last element
if (trailingComma && elements.length > 0 &&
!elements[elements.length - 1].markers.markers.find(m => m.kind === J.Markers.TrailingComma)) {
elements[elements.length - 1].markers.markers.push(trailingComma);
}
});
ret.push(produce(importPadded, draft => {
draft.element = importSorted;
}));
} else {
ret.push(importPadded);
}
}
return ret;
}
/**
* Check if an import has named imports that can be sorted.
*/
private hasNamedImports(import_: JS.Import): boolean {
if (import_.importClause?.namedBindings?.kind === JS.Kind.NamedImports) {
const namedImports = import_.importClause.namedBindings as JS.NamedImports;
return namedImports.elements.kind === J.Kind.Container &&
namedImports.elements.elements.length > 1;
}
return false;
}
/**
* Get the sort key for an import specifier (the original name, not alias).
*/
private getSpecifierSortKey(specifier: JS.ImportSpecifier): string {
if (specifier.specifier.kind === JS.Kind.Alias) {
// import { foo as bar } - sort by 'foo'
return (specifier.specifier as JS.Alias).propertyName.element.simpleName;
} else if (specifier.specifier.kind === J.Kind.Identifier) {
// import { foo } - sort by 'foo'
return (specifier.specifier as J.Identifier).simpleName;
}
return '';
}
}
}
}