Skip to content

Commit dcf9dd9

Browse files
authored
Use immutable import map in JS renderer (#829)
1 parent 412e143 commit dcf9dd9

File tree

8 files changed

+505
-265
lines changed

8 files changed

+505
-265
lines changed

.changeset/itchy-cases-carry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@codama/renderers-js': minor
3+
---
4+
5+
Reimplement `ImportMap` as a functional immutable type

packages/renderers-js/src/fragments/sharedPage.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { pipe } from '@codama/visitors-core';
22

3-
import { addFragmentImportAlias, addFragmentImports, Fragment, fragment } from '../utils';
3+
import { addFragmentImports, Fragment, fragment } from '../utils';
44

55
export function getSharedPageFragment(): Fragment {
66
const sharedPage = fragment`/**
@@ -115,9 +115,8 @@ export function isTransactionSigner<TAddress extends string = string>(value: Add
115115
f =>
116116
addFragmentImports(f, 'solanaSigners', [
117117
'type AccountSignerMeta',
118-
'isTransactionSigner',
118+
'isTransactionSigner as kitIsTransactionSigner',
119119
'type TransactionSigner',
120120
]),
121-
f => addFragmentImportAlias(f, 'solanaSigners', 'isTransactionSigner', 'kitIsTransactionSigner'),
122121
);
123122
}
Lines changed: 24 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { Docs } from '@codama/nodes';
22
import { BaseFragment, createFragmentTemplate } from '@codama/renderers-core';
3-
import { pipe } from '@codama/visitors-core';
43

5-
import { ImportMap } from './importMap';
4+
import {
5+
addToImportMap,
6+
createImportMap,
7+
ImportMap,
8+
importMapToString,
9+
mergeImportMaps,
10+
parseImportInput,
11+
removeFromImportMap,
12+
} from './importMap';
613
import { RenderScope } from './options';
714

815
export type FragmentFeature = 'instruction:resolverScopeVariable';
@@ -14,11 +21,7 @@ export type Fragment = BaseFragment &
1421
}>;
1522

1623
function createFragment(content: string): Fragment {
17-
return Object.freeze({
18-
content,
19-
features: new Set<FragmentFeature>(),
20-
imports: new ImportMap(),
21-
});
24+
return Object.freeze({ content, features: new Set<FragmentFeature>(), imports: createImportMap() });
2225
}
2326

2427
function isFragment(value: unknown): value is Fragment {
@@ -34,57 +37,29 @@ export function mergeFragments(fragments: (Fragment | undefined)[], mergeContent
3437
return Object.freeze({
3538
content: mergeContent(filteredFragments.map(fragment => fragment.content)),
3639
features: new Set(filteredFragments.flatMap(f => [...f.features])),
37-
imports: new ImportMap().mergeWith(...filteredFragments.map(f => f.imports)),
40+
imports: mergeImportMaps(filteredFragments.map(f => f.imports)),
3841
});
3942
}
4043

41-
export function use(pattern: string, module: string): Fragment {
42-
const matches = pattern.match(/^(type )?([^ ]+)(?: as (.+))?$/);
43-
if (!matches) return addFragmentImports(createFragment(pattern), module, [pattern]);
44-
45-
const [_, isType, name, alias] = matches;
46-
const resolvedName = isType ? `type ${name}` : name;
47-
const resolvedAlias = alias ? (isType ? `type ${alias}` : alias) : undefined;
48-
return pipe(
49-
createFragment(alias ? alias : name),
50-
f => addFragmentImports(f, module, [resolvedName]),
51-
f => (resolvedAlias ? addFragmentImportAlias(f, module, resolvedName, resolvedAlias) : f),
52-
);
44+
export function use(importInput: string, module: string): Fragment {
45+
const importInfo = parseImportInput(importInput);
46+
return addFragmentImports(createFragment(importInfo.usedIdentifier), module, [importInput]);
5347
}
5448

5549
export function mergeFragmentImports(fragment: Fragment, importMaps: ImportMap[]): Fragment {
56-
return Object.freeze({
57-
...fragment,
58-
imports: new ImportMap().mergeWith(fragment.imports, ...importMaps),
59-
});
50+
return Object.freeze({ ...fragment, imports: mergeImportMaps([fragment.imports, ...importMaps]) });
6051
}
6152

62-
export function addFragmentImports(fragment: Fragment, module: string, imports: string[]): Fragment {
63-
return Object.freeze({
64-
...fragment,
65-
imports: new ImportMap().mergeWith(fragment.imports).add(module, imports),
66-
});
53+
export function addFragmentImports(fragment: Fragment, module: string, importInputs: string[]): Fragment {
54+
return Object.freeze({ ...fragment, imports: addToImportMap(fragment.imports, module, importInputs) });
6755
}
6856

69-
export function removeFragmentImports(fragment: Fragment, module: string, imports: string[]): Fragment {
70-
return Object.freeze({
71-
...fragment,
72-
imports: new ImportMap().mergeWith(fragment.imports).remove(module, imports),
73-
});
74-
}
75-
76-
export function addFragmentImportAlias(fragment: Fragment, module: string, name: string, alias: string): Fragment {
77-
return Object.freeze({
78-
...fragment,
79-
imports: new ImportMap().mergeWith(fragment.imports).addAlias(module, name, alias),
80-
});
57+
export function removeFragmentImports(fragment: Fragment, module: string, usedIdentifiers: string[]): Fragment {
58+
return Object.freeze({ ...fragment, imports: removeFromImportMap(fragment.imports, module, usedIdentifiers) });
8159
}
8260

8361
export function addFragmentFeatures(fragment: Fragment, features: FragmentFeature[]): Fragment {
84-
return Object.freeze({
85-
...fragment,
86-
features: new Set([...fragment.features, ...features]),
87-
});
62+
return Object.freeze({ ...fragment, features: new Set([...fragment.features, ...features]) });
8863
}
8964

9065
export function getExportAllFragment(module: string): Fragment {
@@ -110,8 +85,9 @@ export function getPageFragment(
11085
'',
11186
'@see https://github.com/codama-idl/codama',
11287
]);
113-
const imports = page.imports.isEmpty()
114-
? undefined
115-
: fragment`${page.imports.toString(scope.dependencyMap, scope.useGranularImports)}`;
88+
const imports =
89+
page.imports.size === 0
90+
? undefined
91+
: fragment`${importMapToString(page.imports, scope.dependencyMap, scope.useGranularImports)}`;
11692
return mergeFragments([header, imports, page], cs => cs.join('\n\n'));
11793
}

packages/renderers-js/src/utils/importMap.ts

Lines changed: 112 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -42,109 +42,128 @@ const DEFAULT_INTERNAL_MODULE_MAP: Record<string, string> = {
4242
types: '../types',
4343
};
4444

45-
export class ImportMap {
46-
protected readonly _imports: Map<string, Set<string>> = new Map();
45+
type ImportInput = string;
46+
type Module = string;
47+
type UsedIdentifier = string;
48+
type ImportInfo = Readonly<{
49+
importedIdentifier: string;
50+
isType: boolean;
51+
usedIdentifier: UsedIdentifier;
52+
}>;
4753

48-
protected readonly _aliases: Map<string, Record<string, string>> = new Map();
54+
export type ImportMap = ReadonlyMap<Module, ReadonlyMap<UsedIdentifier, ImportInfo>>;
4955

50-
add(module: string, imports: Set<string> | string[] | string): ImportMap {
51-
const newImports = new Set(typeof imports === 'string' ? [imports] : imports);
52-
if (newImports.size === 0) return this;
53-
const currentImports = this._imports.get(module) ?? new Set();
54-
newImports.forEach(i => currentImports.add(i));
55-
this._imports.set(module, currentImports);
56-
return this;
57-
}
56+
export function createImportMap(): ImportMap {
57+
return Object.freeze(new Map());
58+
}
5859

59-
remove(module: string, imports: Set<string> | string[] | string): ImportMap {
60-
const importsToRemove = new Set(typeof imports === 'string' ? [imports] : imports);
61-
if (importsToRemove.size === 0) return this;
62-
const currentImports = this._imports.get(module) ?? new Set();
63-
importsToRemove.forEach(i => currentImports.delete(i));
64-
if (currentImports.size === 0) {
65-
this._imports.delete(module);
66-
} else {
67-
this._imports.set(module, currentImports);
68-
}
69-
return this;
70-
}
60+
export function parseImportInput(input: ImportInput): ImportInfo {
61+
const matches = input.match(/^(type )?([^ ]+)(?: as (.+))?$/);
62+
if (!matches) return Object.freeze({ importedIdentifier: input, isType: false, usedIdentifier: input });
7163

72-
mergeWith(...others: ImportMap[]): ImportMap {
73-
others.forEach(other => {
74-
other._imports.forEach((imports, module) => {
75-
this.add(module, imports);
76-
});
77-
other._aliases.forEach((aliases, module) => {
78-
Object.entries(aliases).forEach(([name, alias]) => {
79-
this.addAlias(module, name, alias);
80-
});
81-
});
82-
});
83-
return this;
84-
}
64+
const [_, isType, name, alias] = matches;
65+
return Object.freeze({
66+
importedIdentifier: name,
67+
isType: !!isType,
68+
usedIdentifier: alias ?? name,
69+
});
70+
}
71+
72+
export function addToImportMap(importMap: ImportMap, module: Module, imports: ImportInput[]): ImportMap {
73+
const parsedImports = imports.map(parseImportInput).map(i => [i.usedIdentifier, i] as const);
74+
return mergeImportMaps([importMap, new Map([[module, new Map(parsedImports)]])]);
75+
}
8576

86-
addAlias(module: string, name: string, alias: string): ImportMap {
87-
const currentAliases = this._aliases.get(module) ?? {};
88-
currentAliases[name] = alias;
89-
this._aliases.set(module, currentAliases);
90-
return this;
77+
export function removeFromImportMap(
78+
importMap: ImportMap,
79+
module: Module,
80+
usedIdentifiers: UsedIdentifier[],
81+
): ImportMap {
82+
const newMap = new Map(importMap);
83+
const newModuleMap = new Map(newMap.get(module));
84+
usedIdentifiers.forEach(usedIdentifier => {
85+
newModuleMap.delete(usedIdentifier);
86+
});
87+
if (newModuleMap.size === 0) {
88+
newMap.delete(module);
89+
} else {
90+
newMap.set(module, newModuleMap);
9191
}
92+
return Object.freeze(newMap);
93+
}
9294

93-
isEmpty(): boolean {
94-
return this._imports.size === 0;
95+
export function mergeImportMaps(importMaps: ImportMap[]): ImportMap {
96+
if (importMaps.length === 0) return createImportMap();
97+
if (importMaps.length === 1) return importMaps[0];
98+
const mergedMap = new Map(importMaps[0]);
99+
for (const map of importMaps.slice(1)) {
100+
for (const [module, imports] of map) {
101+
const mergedModuleMap = (mergedMap.get(module) ?? new Map()) as Map<UsedIdentifier, ImportInfo>;
102+
for (const [usedIdentifier, importInfo] of imports) {
103+
const existingImportInfo = mergedModuleMap.get(usedIdentifier);
104+
// If two identical imports exist such that
105+
// one is a type import and the other is not,
106+
// then we must only keep the non-type import.
107+
const shouldOverwriteTypeOnly =
108+
existingImportInfo &&
109+
existingImportInfo.importedIdentifier === importInfo.importedIdentifier &&
110+
existingImportInfo.isType &&
111+
!importInfo.isType;
112+
if (!existingImportInfo || shouldOverwriteTypeOnly) {
113+
mergedModuleMap.set(usedIdentifier, importInfo);
114+
}
115+
}
116+
mergedMap.set(module, mergedModuleMap);
117+
}
95118
}
119+
return Object.freeze(mergedMap);
120+
}
121+
122+
export function importMapToString(
123+
importMap: ImportMap,
124+
dependencyMap: Record<string, string> = {},
125+
useGranularImports = false,
126+
): string {
127+
const resolvedMap = resolveImportMapModules(importMap, dependencyMap, useGranularImports);
96128

97-
resolve(dependencies: Record<string, string> = {}, useGranularImports = false): Map<string, Set<string>> {
98-
// Resolve aliases.
99-
const aliasedMap = new Map<string, Set<string>>(
100-
[...this._imports.entries()].map(([module, imports]) => {
101-
const aliasMap = this._aliases.get(module) ?? {};
102-
const joinedImports = [...imports].map(i => (aliasMap[i] ? `${i} as ${aliasMap[i]}` : i));
103-
return [module, new Set(joinedImports)];
104-
}),
105-
);
129+
return [...resolvedMap.entries()]
130+
.sort(([a], [b]) => {
131+
const relative = Number(a.startsWith('.')) - Number(b.startsWith('.'));
132+
// Relative imports go last.
133+
if (relative !== 0) return relative;
134+
// Otherwise, sort alphabetically.
135+
return a.localeCompare(b);
136+
})
137+
.map(([module, imports]) => {
138+
const innerImports = [...imports.values()]
139+
.map(importInfoToString)
140+
.sort((a, b) => a.localeCompare(b))
141+
.join(', ');
142+
return `import { ${innerImports} } from '${module}';`;
143+
})
144+
.join('\n');
145+
}
106146

107-
// Resolve dependency mappings.
108-
const dependencyMap = {
109-
...(useGranularImports ? DEFAULT_GRANULAR_EXTERNAL_MODULE_MAP : DEFAULT_EXTERNAL_MODULE_MAP),
110-
...DEFAULT_INTERNAL_MODULE_MAP,
111-
...dependencies,
112-
};
113-
const resolvedMap = new Map<string, Set<string>>();
114-
aliasedMap.forEach((imports, module) => {
115-
const resolvedModule: string = dependencyMap[module] ?? module;
116-
const currentImports = resolvedMap.get(resolvedModule) ?? new Set();
117-
imports.forEach(i => currentImports.add(i));
118-
resolvedMap.set(resolvedModule, currentImports);
119-
});
147+
function resolveImportMapModules(
148+
importMap: ImportMap,
149+
dependencyMap: Record<string, string>,
150+
useGranularImports: boolean,
151+
): ImportMap {
152+
const dependencyMapWithDefaults = {
153+
...(useGranularImports ? DEFAULT_GRANULAR_EXTERNAL_MODULE_MAP : DEFAULT_EXTERNAL_MODULE_MAP),
154+
...DEFAULT_INTERNAL_MODULE_MAP,
155+
...dependencyMap,
156+
};
120157

121-
return resolvedMap;
122-
}
158+
return mergeImportMaps(
159+
[...importMap.entries()].map(([module, imports]) => {
160+
const resolvedModule = dependencyMapWithDefaults[module] ?? module;
161+
return new Map([[resolvedModule, imports]]);
162+
}),
163+
);
164+
}
123165

124-
toString(dependencies: Record<string, string> = {}, useGranularImports = false): string {
125-
return [...this.resolve(dependencies, useGranularImports).entries()]
126-
.sort(([a], [b]) => {
127-
const aIsRelative = a.startsWith('.');
128-
const bIsRelative = b.startsWith('.');
129-
if (aIsRelative && !bIsRelative) return 1;
130-
if (!aIsRelative && bIsRelative) return -1;
131-
return a.localeCompare(b);
132-
})
133-
.map(([module, imports]) => {
134-
const joinedImports = [...imports]
135-
.sort()
136-
.filter(i => {
137-
// import of a type can either be '<Type>' or 'type <Type>', so
138-
// we filter out 'type <Type>' variation if there is a '<Type>'
139-
const name = i.split(' ');
140-
if (name.length > 1) {
141-
return !imports.has(name[1]);
142-
}
143-
return true;
144-
})
145-
.join(', ');
146-
return `import { ${joinedImports} } from '${module}';`;
147-
})
148-
.join('\n');
149-
}
166+
function importInfoToString({ importedIdentifier, isType, usedIdentifier }: ImportInfo): string {
167+
const alias = importedIdentifier !== usedIdentifier ? ` as ${usedIdentifier}` : '';
168+
return `${isType ? 'type ' : ''}${importedIdentifier}${alias}`;
150169
}

0 commit comments

Comments
 (0)