-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathCode.ts
292 lines (264 loc) · 9.89 KB
/
Code.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import { Node } from "./Node";
import { emitImports, ImportsName, sameModule, Import, ImportsDefault, ImportsAll } from "./Import";
import { isPlainObject } from "./is-plain-object";
import { ConditionalOutput, MaybeOutput } from "./ConditionalOutput";
import { code } from "./index";
import dprint from "dprint-node";
export type DPrintOptions = Exclude<Parameters<typeof dprint.format>[2], never>;
/** Options for `toString`, i.e. for the top-level, per-file output. */
export interface ToStringOpts {
/** The intended file name of this code; used to know whether we can skip import statements that would be from our own file. */
path?: string;
/** Modules to use a CommonJS-in-ESM destructure fix for, i.e. `Foo@foo` -> `import m from "foo"; m.Foo`. */
forceDefaultImport?: string[];
/** Modules to use a CommonJS-in-ESM destructure fix for, i.e. `Foo@foo` -> `import * as m from "foo"; m.Foo`. */
forceModuleImport?: string[];
/** Modules to use the TypeScript-specific "import via require" fix, i.e. `Long=long` -> `import Long = require("long")`. */
forceRequireImport?: string[];
/** How to handle file extensions in imports, i.e. `import { Foo } from "./foo";` vs `import { Foo } from "./foo.js";`. */
importExtensions?: boolean | "ts" | "js";
/** A top-of-file prefix, i.e. eslint disable. */
prefix?: string;
/** dprint config settings. */
dprintOptions?: DPrintOptions;
/** Whether to format the source or not. */
format?: boolean;
/** optional importMappings */
importMappings?: { [key: string]: string };
}
export class Code extends Node {
// Used by joinCode
public trim: boolean = false;
private oneline: boolean = false;
// Cache the unformatted results of `toString` since we're immutable.
private code: string | undefined;
private codeWithImports: string | undefined;
constructor(
private literals: TemplateStringsArray,
private placeholders: any[],
) {
super();
}
/** Returns the formatted code, with imports. */
toString(opts: ToStringOpts = {}): string {
this.codeWithImports ??= this.generateCodeWithImports(opts);
return opts.format === false ? this.codeWithImports : maybePretty(this.codeWithImports, opts.dprintOptions);
}
asOneline(): Code {
this.oneline = true;
return this;
}
public get childNodes(): unknown[] {
return this.placeholders;
}
/**
* Returns the unformatted, import-less code.
*
* This is an internal API, see `toString` for the public API.
*/
toCodeString(used: ConditionalOutput[]): string {
return (this.code ??= this.generateCode(used));
}
private deepFindAll(): [ConditionalOutput[], Import[], Def[]] {
const used: ConditionalOutput[] = [];
const imports: Import[] = [];
const defs: Def[] = [];
const todo: unknown[] = [this];
let i = 0;
while (i < todo.length) {
const placeholder = todo[i++];
if (placeholder instanceof Node) {
todo.push(...placeholder.childNodes);
} else if (Array.isArray(placeholder)) {
todo.push(...placeholder);
}
if (placeholder instanceof ConditionalOutput) {
used.push(placeholder);
todo.push(...placeholder.declarationSiteCode.childNodes);
} else if (placeholder instanceof Import) {
imports.push(placeholder);
} else if (placeholder instanceof Def) {
defs.push(placeholder);
} else if (placeholder instanceof MaybeOutput) {
if (used.includes(placeholder.parent)) {
todo.push(placeholder.code);
}
}
}
return [used, imports, defs];
}
private deepReplaceNamedImports(forceDefaultImport: string[], forceModuleImport: string[]): void {
// Keep a map of module name --> symbol we're importing, i.e. protobufjs/simple is _m1
const assignedNames: Record<string, string> = {};
function getName(source: string): string {
let name = assignedNames[source];
if (!name) {
name = `_m${Object.values(assignedNames).length}`;
assignedNames[source] = name;
}
return name;
}
const todo: unknown[] = [this];
let i = 0;
while (i < todo.length) {
const placeholder = todo[i++];
if (placeholder instanceof Node) {
const array = placeholder.childNodes;
for (let j = 0; j < array.length; j++) {
const maybeImp = array[j]!;
if (maybeImp instanceof ImportsName && forceDefaultImport.includes(maybeImp.source)) {
const name = getName(maybeImp.source);
array[j] = code`${new ImportsDefault(name, maybeImp.source)}.${maybeImp.sourceSymbol || maybeImp.symbol}`;
} else if (maybeImp instanceof ImportsName && forceModuleImport.includes(maybeImp.source)) {
const name = getName(maybeImp.source);
array[j] = code`${new ImportsAll(name, maybeImp.source)}.${maybeImp.sourceSymbol || maybeImp.symbol}`;
} else if (maybeImp instanceof ImportsDefault && forceModuleImport.includes(maybeImp.source)) {
// Change `import DataLoader from "dataloader"` to `import * as DataLoader from "dataloader"`
array[j] = new ImportsAll(maybeImp.symbol, maybeImp.source);
}
}
todo.push(...placeholder.childNodes);
} else if (Array.isArray(placeholder)) {
todo.push(...placeholder);
}
}
}
private generateCode(used: ConditionalOutput[]): string {
const { literals, placeholders } = this;
let result = "";
// interleave the literals with the placeholders
for (let i = 0; i < placeholders.length; i++) {
result += literals[i] + deepGenerate(used, placeholders[i]);
}
// add the last literal
result += literals[literals.length - 1];
if (this.trim) {
result = result.trim();
}
if (this.oneline) {
result = result.replace(/\n/g, "");
}
return result;
}
private generateCodeWithImports(opts: ToStringOpts): string {
const {
path = "",
forceDefaultImport,
forceModuleImport,
forceRequireImport = [],
importExtensions = true,
prefix,
importMappings = {},
} = opts || {};
const ourModulePath = path.replace(/\.[tj]sx?/, "");
if (forceDefaultImport || forceModuleImport) {
this.deepReplaceNamedImports(forceDefaultImport || [], forceModuleImport || []);
}
const [used, imports, defs] = this.deepFindAll();
assignAliasesIfNeeded(defs, imports, ourModulePath);
const importPart = emitImports(imports, ourModulePath, importMappings, forceRequireImport, importExtensions);
const bodyPart = this.generateCode(used);
const maybePrefix = prefix ? `${prefix}\n` : "";
return maybePrefix + importPart + "\n" + bodyPart;
}
}
export function deepGenerate(used: ConditionalOutput[], object: unknown): string {
let result = "";
let todo: unknown[] = [object];
let i = 0;
while (i < todo.length) {
const current = todo[i++];
if (Array.isArray(current)) {
todo.push(...current);
} else if (current instanceof Node) {
result += current.toCodeString(used);
} else if (current instanceof MaybeOutput) {
if (used.includes(current.parent)) {
result += current.code.toCodeString(used);
}
} else if (current === null) {
result += "null";
} else if (current !== undefined) {
if (isPlainObject(current)) {
result += JSON.stringify(current);
} else {
result += (current as any).toString();
}
} else {
result += "undefined";
}
}
return result;
}
/** Finds any namespace collisions of a named import colliding with def and assigns the import an alias it. */
function assignAliasesIfNeeded(defs: Def[], imports: Import[], ourModulePath: string): void {
// Keep track of used (whether declared or imported) symbols
const usedSymbols = new Set<string>();
// Mark all locally-defined symbols as used
defs.forEach((def) => usedSymbols.add(def.symbol));
// A mapping of original to assigned alias, i.e. Foo@foo --> Foo2
const assignedAliases: Record<string, string> = {};
let j = 1;
imports.forEach((i) => {
if (
i instanceof ImportsName &&
// Don't both aliasing imports from our own module
!(sameModule(i.source, ourModulePath) || (i.definedIn && sameModule(i.definedIn, ourModulePath)))
) {
const key = `${i.symbol}@${i.source}`;
if (usedSymbols.has(i.symbol)) {
let alias = assignedAliases[key];
if (!alias) {
alias = `${i.symbol}${j++}`;
assignedAliases[key] = alias;
}
// Move the original symbol over
if (alias !== i.symbol) {
i.sourceSymbol = i.symbol;
}
i.symbol = alias;
} else {
usedSymbols.add(i.symbol);
assignedAliases[key] = i.symbol;
}
}
});
}
// This default options are both "prettier-ish" plus also suite the ts-poet pre-formatted
// output which is all bunched together, so we want to force braces / force new lines.
const baseOptions: DPrintOptions = {
useTabs: false,
useBraces: "always",
singleBodyPosition: "nextLine",
"arrowFunction.useParentheses": "force",
// dprint-node uses `node: true`, which we want to undo
"module.sortImportDeclarations": "caseSensitive",
lineWidth: 120,
// For some reason dprint seems to wrap lines "before it should" w/o this set (?)
preferSingleLine: true,
};
function maybePretty(input: string, options?: DPrintOptions): string {
try {
return dprint.format("file.ts", input.trim(), { ...baseOptions, ...options });
} catch (e) {
return input; // assume it's invalid syntax and ignore
}
}
/**
* Represents a symbol defined in the current file.
*
* We use this to know if a symbol imported from a different file is going to
* have a namespace collision.
*/
export class Def extends Node {
constructor(public symbol: string) {
super();
}
toCodeString(): string {
return this.symbol;
}
/** Any potentially string/SymbolSpec/Code nested nodes within us. */
get childNodes(): Node[] {
return [];
}
}