Переглянути джерело

Use immutable import map in JS renderer (#829)

Loris Leiva 2 місяців тому
батько
коміт
dcf9dd9bc3

+ 5 - 0
.changeset/itchy-cases-carry.md

@@ -0,0 +1,5 @@
+---
+'@codama/renderers-js': minor
+---
+
+Reimplement `ImportMap` as a functional immutable type

+ 2 - 3
packages/renderers-js/src/fragments/sharedPage.ts

@@ -1,6 +1,6 @@
 import { pipe } from '@codama/visitors-core';
 
-import { addFragmentImportAlias, addFragmentImports, Fragment, fragment } from '../utils';
+import { addFragmentImports, Fragment, fragment } from '../utils';
 
 export function getSharedPageFragment(): Fragment {
     const sharedPage = fragment`/**
@@ -115,9 +115,8 @@ export function isTransactionSigner<TAddress extends string = string>(value: Add
         f =>
             addFragmentImports(f, 'solanaSigners', [
                 'type AccountSignerMeta',
-                'isTransactionSigner',
+                'isTransactionSigner as kitIsTransactionSigner',
                 'type TransactionSigner',
             ]),
-        f => addFragmentImportAlias(f, 'solanaSigners', 'isTransactionSigner', 'kitIsTransactionSigner'),
     );
 }

+ 24 - 48
packages/renderers-js/src/utils/fragment.ts

@@ -1,8 +1,15 @@
 import { Docs } from '@codama/nodes';
 import { BaseFragment, createFragmentTemplate } from '@codama/renderers-core';
-import { pipe } from '@codama/visitors-core';
 
-import { ImportMap } from './importMap';
+import {
+    addToImportMap,
+    createImportMap,
+    ImportMap,
+    importMapToString,
+    mergeImportMaps,
+    parseImportInput,
+    removeFromImportMap,
+} from './importMap';
 import { RenderScope } from './options';
 
 export type FragmentFeature = 'instruction:resolverScopeVariable';
@@ -14,11 +21,7 @@ export type Fragment = BaseFragment &
     }>;
 
 function createFragment(content: string): Fragment {
-    return Object.freeze({
-        content,
-        features: new Set<FragmentFeature>(),
-        imports: new ImportMap(),
-    });
+    return Object.freeze({ content, features: new Set<FragmentFeature>(), imports: createImportMap() });
 }
 
 function isFragment(value: unknown): value is Fragment {
@@ -34,57 +37,29 @@ export function mergeFragments(fragments: (Fragment | undefined)[], mergeContent
     return Object.freeze({
         content: mergeContent(filteredFragments.map(fragment => fragment.content)),
         features: new Set(filteredFragments.flatMap(f => [...f.features])),
-        imports: new ImportMap().mergeWith(...filteredFragments.map(f => f.imports)),
+        imports: mergeImportMaps(filteredFragments.map(f => f.imports)),
     });
 }
 
-export function use(pattern: string, module: string): Fragment {
-    const matches = pattern.match(/^(type )?([^ ]+)(?: as (.+))?$/);
-    if (!matches) return addFragmentImports(createFragment(pattern), module, [pattern]);
-
-    const [_, isType, name, alias] = matches;
-    const resolvedName = isType ? `type ${name}` : name;
-    const resolvedAlias = alias ? (isType ? `type ${alias}` : alias) : undefined;
-    return pipe(
-        createFragment(alias ? alias : name),
-        f => addFragmentImports(f, module, [resolvedName]),
-        f => (resolvedAlias ? addFragmentImportAlias(f, module, resolvedName, resolvedAlias) : f),
-    );
+export function use(importInput: string, module: string): Fragment {
+    const importInfo = parseImportInput(importInput);
+    return addFragmentImports(createFragment(importInfo.usedIdentifier), module, [importInput]);
 }
 
 export function mergeFragmentImports(fragment: Fragment, importMaps: ImportMap[]): Fragment {
-    return Object.freeze({
-        ...fragment,
-        imports: new ImportMap().mergeWith(fragment.imports, ...importMaps),
-    });
+    return Object.freeze({ ...fragment, imports: mergeImportMaps([fragment.imports, ...importMaps]) });
 }
 
-export function addFragmentImports(fragment: Fragment, module: string, imports: string[]): Fragment {
-    return Object.freeze({
-        ...fragment,
-        imports: new ImportMap().mergeWith(fragment.imports).add(module, imports),
-    });
+export function addFragmentImports(fragment: Fragment, module: string, importInputs: string[]): Fragment {
+    return Object.freeze({ ...fragment, imports: addToImportMap(fragment.imports, module, importInputs) });
 }
 
-export function removeFragmentImports(fragment: Fragment, module: string, imports: string[]): Fragment {
-    return Object.freeze({
-        ...fragment,
-        imports: new ImportMap().mergeWith(fragment.imports).remove(module, imports),
-    });
-}
-
-export function addFragmentImportAlias(fragment: Fragment, module: string, name: string, alias: string): Fragment {
-    return Object.freeze({
-        ...fragment,
-        imports: new ImportMap().mergeWith(fragment.imports).addAlias(module, name, alias),
-    });
+export function removeFragmentImports(fragment: Fragment, module: string, usedIdentifiers: string[]): Fragment {
+    return Object.freeze({ ...fragment, imports: removeFromImportMap(fragment.imports, module, usedIdentifiers) });
 }
 
 export function addFragmentFeatures(fragment: Fragment, features: FragmentFeature[]): Fragment {
-    return Object.freeze({
-        ...fragment,
-        features: new Set([...fragment.features, ...features]),
-    });
+    return Object.freeze({ ...fragment, features: new Set([...fragment.features, ...features]) });
 }
 
 export function getExportAllFragment(module: string): Fragment {
@@ -110,8 +85,9 @@ export function getPageFragment(
         '',
         '@see https://github.com/codama-idl/codama',
     ]);
-    const imports = page.imports.isEmpty()
-        ? undefined
-        : fragment`${page.imports.toString(scope.dependencyMap, scope.useGranularImports)}`;
+    const imports =
+        page.imports.size === 0
+            ? undefined
+            : fragment`${importMapToString(page.imports, scope.dependencyMap, scope.useGranularImports)}`;
     return mergeFragments([header, imports, page], cs => cs.join('\n\n'));
 }

+ 112 - 93
packages/renderers-js/src/utils/importMap.ts

@@ -42,109 +42,128 @@ const DEFAULT_INTERNAL_MODULE_MAP: Record<string, string> = {
     types: '../types',
 };
 
-export class ImportMap {
-    protected readonly _imports: Map<string, Set<string>> = new Map();
+type ImportInput = string;
+type Module = string;
+type UsedIdentifier = string;
+type ImportInfo = Readonly<{
+    importedIdentifier: string;
+    isType: boolean;
+    usedIdentifier: UsedIdentifier;
+}>;
 
-    protected readonly _aliases: Map<string, Record<string, string>> = new Map();
+export type ImportMap = ReadonlyMap<Module, ReadonlyMap<UsedIdentifier, ImportInfo>>;
 
-    add(module: string, imports: Set<string> | string[] | string): ImportMap {
-        const newImports = new Set(typeof imports === 'string' ? [imports] : imports);
-        if (newImports.size === 0) return this;
-        const currentImports = this._imports.get(module) ?? new Set();
-        newImports.forEach(i => currentImports.add(i));
-        this._imports.set(module, currentImports);
-        return this;
-    }
+export function createImportMap(): ImportMap {
+    return Object.freeze(new Map());
+}
 
-    remove(module: string, imports: Set<string> | string[] | string): ImportMap {
-        const importsToRemove = new Set(typeof imports === 'string' ? [imports] : imports);
-        if (importsToRemove.size === 0) return this;
-        const currentImports = this._imports.get(module) ?? new Set();
-        importsToRemove.forEach(i => currentImports.delete(i));
-        if (currentImports.size === 0) {
-            this._imports.delete(module);
-        } else {
-            this._imports.set(module, currentImports);
-        }
-        return this;
-    }
+export function parseImportInput(input: ImportInput): ImportInfo {
+    const matches = input.match(/^(type )?([^ ]+)(?: as (.+))?$/);
+    if (!matches) return Object.freeze({ importedIdentifier: input, isType: false, usedIdentifier: input });
 
-    mergeWith(...others: ImportMap[]): ImportMap {
-        others.forEach(other => {
-            other._imports.forEach((imports, module) => {
-                this.add(module, imports);
-            });
-            other._aliases.forEach((aliases, module) => {
-                Object.entries(aliases).forEach(([name, alias]) => {
-                    this.addAlias(module, name, alias);
-                });
-            });
-        });
-        return this;
-    }
+    const [_, isType, name, alias] = matches;
+    return Object.freeze({
+        importedIdentifier: name,
+        isType: !!isType,
+        usedIdentifier: alias ?? name,
+    });
+}
+
+export function addToImportMap(importMap: ImportMap, module: Module, imports: ImportInput[]): ImportMap {
+    const parsedImports = imports.map(parseImportInput).map(i => [i.usedIdentifier, i] as const);
+    return mergeImportMaps([importMap, new Map([[module, new Map(parsedImports)]])]);
+}
 
-    addAlias(module: string, name: string, alias: string): ImportMap {
-        const currentAliases = this._aliases.get(module) ?? {};
-        currentAliases[name] = alias;
-        this._aliases.set(module, currentAliases);
-        return this;
+export function removeFromImportMap(
+    importMap: ImportMap,
+    module: Module,
+    usedIdentifiers: UsedIdentifier[],
+): ImportMap {
+    const newMap = new Map(importMap);
+    const newModuleMap = new Map(newMap.get(module));
+    usedIdentifiers.forEach(usedIdentifier => {
+        newModuleMap.delete(usedIdentifier);
+    });
+    if (newModuleMap.size === 0) {
+        newMap.delete(module);
+    } else {
+        newMap.set(module, newModuleMap);
     }
+    return Object.freeze(newMap);
+}
 
-    isEmpty(): boolean {
-        return this._imports.size === 0;
+export function mergeImportMaps(importMaps: ImportMap[]): ImportMap {
+    if (importMaps.length === 0) return createImportMap();
+    if (importMaps.length === 1) return importMaps[0];
+    const mergedMap = new Map(importMaps[0]);
+    for (const map of importMaps.slice(1)) {
+        for (const [module, imports] of map) {
+            const mergedModuleMap = (mergedMap.get(module) ?? new Map()) as Map<UsedIdentifier, ImportInfo>;
+            for (const [usedIdentifier, importInfo] of imports) {
+                const existingImportInfo = mergedModuleMap.get(usedIdentifier);
+                // If two identical imports exist such that
+                // one is a type import and the other is not,
+                // then we must only keep the non-type import.
+                const shouldOverwriteTypeOnly =
+                    existingImportInfo &&
+                    existingImportInfo.importedIdentifier === importInfo.importedIdentifier &&
+                    existingImportInfo.isType &&
+                    !importInfo.isType;
+                if (!existingImportInfo || shouldOverwriteTypeOnly) {
+                    mergedModuleMap.set(usedIdentifier, importInfo);
+                }
+            }
+            mergedMap.set(module, mergedModuleMap);
+        }
     }
+    return Object.freeze(mergedMap);
+}
+
+export function importMapToString(
+    importMap: ImportMap,
+    dependencyMap: Record<string, string> = {},
+    useGranularImports = false,
+): string {
+    const resolvedMap = resolveImportMapModules(importMap, dependencyMap, useGranularImports);
 
-    resolve(dependencies: Record<string, string> = {}, useGranularImports = false): Map<string, Set<string>> {
-        // Resolve aliases.
-        const aliasedMap = new Map<string, Set<string>>(
-            [...this._imports.entries()].map(([module, imports]) => {
-                const aliasMap = this._aliases.get(module) ?? {};
-                const joinedImports = [...imports].map(i => (aliasMap[i] ? `${i} as ${aliasMap[i]}` : i));
-                return [module, new Set(joinedImports)];
-            }),
-        );
+    return [...resolvedMap.entries()]
+        .sort(([a], [b]) => {
+            const relative = Number(a.startsWith('.')) - Number(b.startsWith('.'));
+            // Relative imports go last.
+            if (relative !== 0) return relative;
+            // Otherwise, sort alphabetically.
+            return a.localeCompare(b);
+        })
+        .map(([module, imports]) => {
+            const innerImports = [...imports.values()]
+                .map(importInfoToString)
+                .sort((a, b) => a.localeCompare(b))
+                .join(', ');
+            return `import { ${innerImports} } from '${module}';`;
+        })
+        .join('\n');
+}
 
-        // Resolve dependency mappings.
-        const dependencyMap = {
-            ...(useGranularImports ? DEFAULT_GRANULAR_EXTERNAL_MODULE_MAP : DEFAULT_EXTERNAL_MODULE_MAP),
-            ...DEFAULT_INTERNAL_MODULE_MAP,
-            ...dependencies,
-        };
-        const resolvedMap = new Map<string, Set<string>>();
-        aliasedMap.forEach((imports, module) => {
-            const resolvedModule: string = dependencyMap[module] ?? module;
-            const currentImports = resolvedMap.get(resolvedModule) ?? new Set();
-            imports.forEach(i => currentImports.add(i));
-            resolvedMap.set(resolvedModule, currentImports);
-        });
+function resolveImportMapModules(
+    importMap: ImportMap,
+    dependencyMap: Record<string, string>,
+    useGranularImports: boolean,
+): ImportMap {
+    const dependencyMapWithDefaults = {
+        ...(useGranularImports ? DEFAULT_GRANULAR_EXTERNAL_MODULE_MAP : DEFAULT_EXTERNAL_MODULE_MAP),
+        ...DEFAULT_INTERNAL_MODULE_MAP,
+        ...dependencyMap,
+    };
 
-        return resolvedMap;
-    }
+    return mergeImportMaps(
+        [...importMap.entries()].map(([module, imports]) => {
+            const resolvedModule = dependencyMapWithDefaults[module] ?? module;
+            return new Map([[resolvedModule, imports]]);
+        }),
+    );
+}
 
-    toString(dependencies: Record<string, string> = {}, useGranularImports = false): string {
-        return [...this.resolve(dependencies, useGranularImports).entries()]
-            .sort(([a], [b]) => {
-                const aIsRelative = a.startsWith('.');
-                const bIsRelative = b.startsWith('.');
-                if (aIsRelative && !bIsRelative) return 1;
-                if (!aIsRelative && bIsRelative) return -1;
-                return a.localeCompare(b);
-            })
-            .map(([module, imports]) => {
-                const joinedImports = [...imports]
-                    .sort()
-                    .filter(i => {
-                        // import of a type can either be '<Type>' or 'type <Type>', so
-                        // we filter out 'type <Type>' variation if there is a '<Type>'
-                        const name = i.split(' ');
-                        if (name.length > 1) {
-                            return !imports.has(name[1]);
-                        }
-                        return true;
-                    })
-                    .join(', ');
-                return `import { ${joinedImports} } from '${module}';`;
-            })
-            .join('\n');
-    }
+function importInfoToString({ importedIdentifier, isType, usedIdentifier }: ImportInfo): string {
+    const alias = importedIdentifier !== usedIdentifier ? ` as ${usedIdentifier}` : '';
+    return `${isType ? 'type ' : ''}${importedIdentifier}${alias}`;
 }

+ 0 - 108
packages/renderers-js/test/ImportMap.test.ts

@@ -1,108 +0,0 @@
-import { expect, test } from 'vitest';
-
-import { ImportMap } from '../src';
-
-test('it renders JavaScript import statements', () => {
-    // Given an import map with 3 imports from 2 sources.
-    const importMap = new ImportMap()
-        .add('@solana/addresses', ['getAddressEncoder', 'type Address'])
-        .add('@solana/instructions', 'type InstructionWithData');
-
-    // When we render it.
-    const importStatements = importMap.toString();
-
-    // Then we expect the following import statements.
-    expect(importStatements).toBe(
-        "import { getAddressEncoder, type Address } from '@solana/addresses';\n" +
-            "import { type InstructionWithData } from '@solana/instructions';",
-    );
-});
-
-test('it renders JavaScript import aliases', () => {
-    // Given an import map with an import alias.
-    const importMap = new ImportMap()
-        .add('@solana/addresses', 'type Address')
-        .addAlias('@solana/addresses', 'type Address', 'SolanaAddress');
-
-    // When we render it.
-    const importStatements = importMap.toString();
-
-    // Then we expect the following import statement.
-    expect(importStatements).toBe("import { type Address as SolanaAddress } from '@solana/addresses';");
-});
-
-test('it offers some default dependency mappings', () => {
-    // Given an import map with some recognized dependency keys.
-    const importMap = new ImportMap()
-        .add('solanaAddresses', 'type Address')
-        .add('solanaCodecsCore', 'type Codec')
-        .add('generatedTypes', 'type MyType')
-        .add('shared', 'myHelper')
-        .add('hooked', 'type MyCustomType');
-
-    // When we render it.
-    const importStatements = importMap.toString();
-
-    // Then we expect the following import statements.
-    expect(importStatements).toBe(
-        "import { type Address, type Codec } from '@solana/kit';\n" +
-            "import { type MyCustomType } from '../../hooked';\n" +
-            "import { myHelper } from '../shared';\n" +
-            "import { type MyType } from '../types';",
-    );
-});
-
-test('it offers some more granular default dependency mappings', () => {
-    // Given an import map with some recognized dependency keys.
-    const importMap = new ImportMap()
-        .add('solanaAddresses', 'type Address')
-        .add('solanaCodecsCore', 'type Codec')
-        .add('generatedTypes', 'type MyType')
-        .add('shared', 'myHelper')
-        .add('hooked', 'type MyCustomType');
-
-    // When we render it.
-    const importStatements = importMap.toString({}, true);
-
-    // Then we expect the following import statements.
-    expect(importStatements).toBe(
-        "import { type Address } from '@solana/addresses';\n" +
-            "import { type Codec } from '@solana/codecs';\n" +
-            "import { type MyCustomType } from '../../hooked';\n" +
-            "import { myHelper } from '../shared';\n" +
-            "import { type MyType } from '../types';",
-    );
-});
-
-test('it supports custom dependency mappings', () => {
-    // Given an import map with some custom dependency keys.
-    const importMap = new ImportMap().add('myDependency', 'type MyType');
-
-    // When we render it whilst providing custom dependency mappings.
-    const importStatements = importMap.toString({
-        myDependency: 'my/custom/path',
-    });
-
-    // Then we expect the following import statement.
-    expect(importStatements).toBe("import { type MyType } from 'my/custom/path';");
-});
-
-test('it does not render empty import statements', () => {
-    expect(new ImportMap().toString()).toBe('');
-    expect(new ImportMap().add('shared', []).toString()).toBe('');
-    expect(new ImportMap().addAlias('shared', 'Foo', 'Bar').toString()).toBe('');
-});
-
-test('it merges imports that have the same aliases together', () => {
-    // Given an import map with some recognized dependency keys.
-    const importMap = new ImportMap().add('packageA', 'foo').add('packageB', 'bar');
-
-    // When we render it.
-    const importStatements = importMap.toString({
-        packageA: '@solana/packages',
-        packageB: '@solana/packages',
-    });
-
-    // Then we expect the following import statements.
-    expect(importStatements).toBe("import { bar, foo } from '@solana/packages';");
-});

+ 1 - 1
packages/renderers-js/test/accountsPage.test.ts

@@ -82,7 +82,7 @@ test('it renders an account with a defined type link as discriminator', async ()
     const renderMap = visit(node, getRenderMapVisitor());
 
     // Then we expect the following import list with a reference to the disciminator type.
-    await renderMapContains(renderMap, 'accounts/asset.ts', ['import { Key, getKeyDecoder, getKeyEncoder }']);
+    await renderMapContains(renderMap, 'accounts/asset.ts', ['import { getKeyDecoder, getKeyEncoder, Key }']);
 });
 
 test('it renders constants for account field discriminators', async () => {

+ 3 - 12
packages/renderers-js/test/utils/fragment.test.ts

@@ -1,7 +1,6 @@
-import { pipe } from '@codama/visitors-core';
 import { describe, expect, test } from 'vitest';
 
-import { addFragmentImportAlias, addFragmentImports, fragment, use } from '../../src/utils';
+import { addFragmentImports, fragment, use } from '../../src/utils';
 
 describe('use', () => {
     test('it creates a fragment with an import of the same name', () => {
@@ -18,21 +17,13 @@ describe('use', () => {
 
     test('it creates a fragment with an alias', () => {
         expect(use('address as myAddress', '@solana/addresses')).toStrictEqual(
-            pipe(
-                fragment`myAddress`,
-                f => addFragmentImports(f, '@solana/addresses', ['address']),
-                f => addFragmentImportAlias(f, '@solana/addresses', 'address', 'myAddress'),
-            ),
+            addFragmentImports(fragment`myAddress`, '@solana/addresses', ['address as myAddress']),
         );
     });
 
     test('it creates a fragment with a type alias', () => {
         expect(use('type Address as MyAddress', '@solana/addresses')).toStrictEqual(
-            pipe(
-                fragment`MyAddress`,
-                f => addFragmentImports(f, '@solana/addresses', ['type Address']),
-                f => addFragmentImportAlias(f, '@solana/addresses', 'type Address', 'type MyAddress'),
-            ),
+            addFragmentImports(fragment`MyAddress`, '@solana/addresses', ['type Address as MyAddress']),
         );
     });
 });

+ 358 - 0
packages/renderers-js/test/utils/importMap.test.ts

@@ -0,0 +1,358 @@
+import { pipe } from '@codama/visitors-core';
+import { describe, expect, test } from 'vitest';
+
+import {
+    addToImportMap,
+    createImportMap,
+    importMapToString,
+    mergeImportMaps,
+    parseImportInput,
+    removeFromImportMap,
+} from '../../src/utils';
+
+describe('createImportMap', () => {
+    test('it creates an empty import map', () => {
+        expect(createImportMap()).toStrictEqual(new Map());
+    });
+});
+
+describe('parseImportInput', () => {
+    test('it parses simple identifiers', () => {
+        expect(parseImportInput('myFunction')).toStrictEqual({
+            importedIdentifier: 'myFunction',
+            isType: false,
+            usedIdentifier: 'myFunction',
+        });
+    });
+
+    test('it parses type-only identifiers', () => {
+        expect(parseImportInput('type MyType')).toStrictEqual({
+            importedIdentifier: 'MyType',
+            isType: true,
+            usedIdentifier: 'MyType',
+        });
+    });
+
+    test('it parses aliased identifiers', () => {
+        expect(parseImportInput('myFunction as myAliasedFunction')).toStrictEqual({
+            importedIdentifier: 'myFunction',
+            isType: false,
+            usedIdentifier: 'myAliasedFunction',
+        });
+    });
+
+    test('it parses type-only aliased identifiers', () => {
+        expect(parseImportInput('type MyType as MyAliasedType')).toStrictEqual({
+            importedIdentifier: 'MyType',
+            isType: true,
+            usedIdentifier: 'MyAliasedType',
+        });
+    });
+
+    test('it parses unrecognised patterns as-is', () => {
+        expect(parseImportInput('!some weird #input')).toStrictEqual({
+            importedIdentifier: '!some weird #input',
+            isType: false,
+            usedIdentifier: '!some weird #input',
+        });
+    });
+});
+
+describe('mergeImportMaps', () => {
+    test('it returns an empty map when merging empty maps', () => {
+        expect(mergeImportMaps([])).toStrictEqual(new Map());
+    });
+
+    test('it returns the first map as-is when merging a single map', () => {
+        const map = createImportMap();
+        expect(mergeImportMaps([map])).toBe(map);
+    });
+
+    test('it add all modules from the merged maps', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-b', ['import2']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([
+                ['module-a', new Map([['import1', parseImportInput('import1')]])],
+                ['module-b', new Map([['import2', parseImportInput('import2')]])],
+            ]),
+        );
+    });
+
+    test('it merges imports from the same module', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import2']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([
+                [
+                    'module-a',
+                    new Map([
+                        ['import1', parseImportInput('import1')],
+                        ['import2', parseImportInput('import2')],
+                    ]),
+                ],
+            ]),
+        );
+    });
+
+    test('it keeps the same import from the same module only once', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([['module-a', new Map([['import1', parseImportInput('import1')]])]]),
+        );
+    });
+
+    test('it keeps multiple instances of the same import when one is aliased', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([
+                [
+                    'module-a',
+                    new Map([
+                        ['import1', parseImportInput('import1')],
+                        ['alias1', parseImportInput('import1 as alias1')],
+                    ]),
+                ],
+            ]),
+        );
+    });
+
+    test('it keeps multiple instances of the same import when both are aliased to different names', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias2']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([
+                [
+                    'module-a',
+                    new Map([
+                        ['alias1', parseImportInput('import1 as alias1')],
+                        ['alias2', parseImportInput('import1 as alias2')],
+                    ]),
+                ],
+            ]),
+        );
+    });
+
+    test('it keeps the same import from the same module when aliased to the same name', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([['module-a', new Map([['alias1', parseImportInput('import1 as alias1')]])]]),
+        );
+    });
+
+    test('it keeps the same type-only import from the same module only once', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['type import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['type import1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([['module-a', new Map([['import1', parseImportInput('type import1')]])]]),
+        );
+    });
+
+    test('it keeps the same type-only import with the same aliased name from the same module only once', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['type import1 as alias1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['type import1 as alias1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([['module-a', new Map([['alias1', parseImportInput('type import1 as alias1')]])]]),
+        );
+    });
+
+    test('it only keep the non-type variant instead of the type-only variant when both have the same name', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['type import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([['module-a', new Map([['import1', parseImportInput('import1')]])]]),
+        );
+    });
+
+    test('it only keep the non-type variant instead of the type-only variant when both are aliased to the same name', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['type import1 as alias1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([['module-a', new Map([['alias1', parseImportInput('import1 as alias1')]])]]),
+        );
+    });
+
+    test('it keeps both the non-type and type-only imports when the former is aliased', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['type import1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([
+                [
+                    'module-a',
+                    new Map([
+                        ['alias1', parseImportInput('import1 as alias1')],
+                        ['import1', parseImportInput('type import1')],
+                    ]),
+                ],
+            ]),
+        );
+    });
+
+    test('it keeps both the non-type and type-only imports when the latter is aliased', () => {
+        const map1 = addToImportMap(createImportMap(), 'module-a', ['import1']);
+        const map2 = addToImportMap(createImportMap(), 'module-a', ['type import1 as alias1']);
+        expect(mergeImportMaps([map1, map2])).toStrictEqual(
+            new Map([
+                [
+                    'module-a',
+                    new Map([
+                        ['import1', parseImportInput('import1')],
+                        ['alias1', parseImportInput('type import1 as alias1')],
+                    ]),
+                ],
+            ]),
+        );
+    });
+});
+
+describe('addToImportMap', () => {
+    test('it adds imports to an empty import map', () => {
+        expect(
+            addToImportMap(createImportMap(), 'module-a', ['import1', 'type import2', 'import3 as alias3']),
+        ).toStrictEqual(
+            new Map([
+                [
+                    'module-a',
+                    new Map([
+                        ['import1', parseImportInput('import1')],
+                        ['import2', parseImportInput('type import2')],
+                        ['alias3', parseImportInput('import3 as alias3')],
+                    ]),
+                ],
+            ]),
+        );
+    });
+
+    test('it adds imports to an existing import map', () => {
+        expect(
+            pipe(
+                createImportMap(),
+                m => addToImportMap(m, 'module-a', ['import1']),
+                m => addToImportMap(m, 'module-b', ['import2']),
+            ),
+        ).toStrictEqual(
+            new Map([
+                ['module-a', new Map([['import1', parseImportInput('import1')]])],
+                ['module-b', new Map([['import2', parseImportInput('import2')]])],
+            ]),
+        );
+    });
+});
+
+describe('removeFromImportMap', () => {
+    test('it removes type-only imports from an existing import map', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import1', 'type import2']);
+        expect(removeFromImportMap(importMap, 'module-a', ['import1'])).toStrictEqual(
+            new Map([['module-a', new Map([['import2', parseImportInput('type import2')]])]]),
+        );
+    });
+
+    test('it removes type-only imports from an existing import map', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import1', 'type import2']);
+        expect(removeFromImportMap(importMap, 'module-a', ['import2'])).toStrictEqual(
+            new Map([['module-a', new Map([['import1', parseImportInput('import1')]])]]),
+        );
+    });
+
+    test('it removes aliased imports from an existing import map using the aliased name', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import1', 'import2 as alias2']);
+        expect(removeFromImportMap(importMap, 'module-a', ['alias2'])).toStrictEqual(
+            new Map([['module-a', new Map([['import1', parseImportInput('import1')]])]]),
+        );
+    });
+
+    test('it removes multiple imports from the same module', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', [
+            'import1',
+            'import2 as alias2',
+            'type import3',
+        ]);
+        expect(removeFromImportMap(importMap, 'module-a', ['alias2', 'import3'])).toStrictEqual(
+            new Map([['module-a', new Map([['import1', parseImportInput('import1')]])]]),
+        );
+    });
+
+    test('it removes the module map when it is empty', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import1', 'import2']);
+        expect(removeFromImportMap(importMap, 'module-a', ['import1', 'import2'])).toStrictEqual(new Map());
+    });
+});
+
+describe('importMapToString', () => {
+    test('it converts an import map to a valid import statement', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import1', 'import2', 'import3']);
+        expect(importMapToString(importMap)).toBe("import { import1, import2, import3 } from 'module-a';");
+    });
+
+    test('it supports type-only import statements', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['type import1']);
+        expect(importMapToString(importMap)).toBe("import { type import1 } from 'module-a';");
+    });
+
+    test('it supports aliased import statements', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import1 as alias1']);
+        expect(importMapToString(importMap)).toBe("import { import1 as alias1 } from 'module-a';");
+    });
+
+    test('it supports aliased type-only import statements', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['type import1 as alias1']);
+        expect(importMapToString(importMap)).toBe("import { type import1 as alias1 } from 'module-a';");
+    });
+
+    test('it orders import items alphabetically', () => {
+        const importMap = addToImportMap(createImportMap(), 'module-a', ['import3', 'import1', 'import2']);
+        expect(importMapToString(importMap)).toBe("import { import1, import2, import3 } from 'module-a';");
+    });
+
+    test('it orders import statements alphabetically', () => {
+        const importMap = pipe(
+            createImportMap(),
+            m => addToImportMap(m, 'module-b', ['import2']),
+            m => addToImportMap(m, 'module-a', ['import1']),
+        );
+        expect(importMapToString(importMap)).toBe(
+            "import { import1 } from 'module-a';\nimport { import2 } from 'module-b';",
+        );
+    });
+
+    test('it places absolute imports before relative imports', () => {
+        const importMap = pipe(
+            createImportMap(),
+            m => addToImportMap(m, './relative-module', ['import1']),
+            m => addToImportMap(m, 'absolute-module', ['import2']),
+        );
+        expect(importMapToString(importMap)).toBe(
+            "import { import2 } from 'absolute-module';\nimport { import1 } from './relative-module';",
+        );
+    });
+
+    test('it replaces internal modules with their actual paths', () => {
+        const importMap = addToImportMap(createImportMap(), 'generatedAccounts', ['myAccount']);
+        expect(importMapToString(importMap)).toBe("import { myAccount } from '../accounts';");
+    });
+
+    test('it can override the paths of internal modules', () => {
+        const importMap = addToImportMap(createImportMap(), 'generatedAccounts', ['myAccount']);
+        expect(importMapToString(importMap, { generatedAccounts: '.' })).toBe("import { myAccount } from '.';");
+    });
+
+    test('it replaces placeholder kit packages with their @solana/kit', () => {
+        const importMap = addToImportMap(createImportMap(), 'solanaAddresses', ['type Address']);
+        expect(importMapToString(importMap)).toBe("import { type Address } from '@solana/kit';");
+    });
+
+    test('it can use granular packages when replacing placeholder kit packages', () => {
+        const importMap = addToImportMap(createImportMap(), 'solanaAddresses', ['type Address']);
+        expect(importMapToString(importMap, {}, true)).toBe("import { type Address } from '@solana/addresses';");
+    });
+
+    test('it can override the module of placeholder kit packages', () => {
+        const importMap = addToImportMap(createImportMap(), 'solanaAddresses', ['type Address']);
+        expect(importMapToString(importMap, { solanaAddresses: '@acme/solana-addresses' })).toBe(
+            "import { type Address } from '@acme/solana-addresses';",
+        );
+    });
+});