fork of hey-api/openapi-ts because I need some additional things
1import path from 'node:path';
2
3import type { ExportModule, ImportModule } from '../bindings';
4import type { IProjectRenderMeta } from '../extensions';
5import type { File } from '../files/file';
6import type { INode } from '../nodes/node';
7import { canShareName } from '../project/namespace';
8import type { IProject } from '../project/types';
9import { fromRef } from '../refs/refs';
10import type { RenderContext } from '../renderer';
11import type { Symbol } from '../symbols/symbol';
12import type { SymbolKind } from '../symbols/types';
13import type { AnalysisContext } from './analyzer';
14import { Analyzer } from './analyzer';
15import type { AssignOptions, Scope } from './scope';
16import { createScope } from './scope';
17
18const isTypeOnlyKind = (kind: SymbolKind) => kind === 'type' || kind === 'interface';
19
20export class Planner {
21 private readonly analyzer = new Analyzer();
22 private readonly cacheResolvedNames = new Set<number>();
23 private readonly project: IProject;
24
25 constructor(project: IProject) {
26 this.project = project;
27 }
28
29 /**
30 * Executes the planning phase for the project.
31 */
32 plan(meta?: IProjectRenderMeta) {
33 this.cacheResolvedNames.clear();
34 this.allocateFiles();
35 this.assignLocalNames();
36 this.resolveFilePaths(meta);
37 this.planExports();
38 this.planImports();
39 }
40
41 /**
42 * Creates and assigns a file to every node, re-export,
43 * and external dependency.
44 */
45 private allocateFiles(): void {
46 this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => {
47 const symbol = node.symbol;
48 if (!symbol) return;
49
50 const file = this.project.files.register({
51 external: false,
52 language: node.language,
53 logicalFilePath: symbol.getFilePath?.(symbol) || this.project.defaultFileName,
54 });
55 file.addNode(node);
56 symbol.setFile(file);
57 for (const logicalFilePath of symbol.getExportFromFilePath?.(symbol) ?? []) {
58 this.project.files.register({
59 external: false,
60 language: file.language,
61 logicalFilePath,
62 });
63 }
64 ctx.walkScopes((dependency) => {
65 const dep = fromRef(dependency);
66 if (dep.external && dep.isCanonical && !dep.file) {
67 const file = this.project.files.register({
68 external: true,
69 language: dep.node?.language,
70 logicalFilePath: dep.external,
71 });
72 dep.setFile(file);
73 }
74 });
75 });
76 }
77
78 /**
79 * Assigns final names to all symbols.
80 *
81 * First assigns top-level (file-scoped) symbol names, then local symbols.
82 */
83 private assignLocalNames(): void {
84 this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => {
85 const symbol = node.symbol;
86 if (!symbol) return;
87 this.assignTopLevelName({ ctx, node, symbol });
88 });
89
90 this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => {
91 const file = node.file;
92 if (!file) return;
93 ctx.walkScopes((dependency) => {
94 const dep = fromRef(dependency);
95 // top-level or external symbol
96 if (dep.file) return;
97 // TODO: pass node
98 this.assignLocalName({
99 ctx,
100 file,
101 scopesToUpdate: [createScope({ localNames: file.allNames })],
102 symbol: dep,
103 });
104 });
105 });
106 }
107
108 /**
109 * Resolves and sets final file paths for all non-external files. Attaches renderers.
110 *
111 * Uses the project's fileName function if provided, otherwise uses the file's current name.
112 *
113 * Resolves final paths relative to the project's root directory.
114 */
115 private resolveFilePaths(meta?: IProjectRenderMeta): void {
116 for (const file of this.project.files.registered()) {
117 if (file.external) {
118 file.setFinalPath(file.logicalFilePath);
119 continue;
120 }
121 const finalName = this.project.fileName?.(file.name) || file.name;
122 file.setName(finalName);
123 const finalPath = file.finalPath;
124 if (finalPath) {
125 file.setFinalPath(path.resolve(this.project.root, finalPath));
126 }
127 const ctx: RenderContext = { file, meta, project: this.project };
128 const renderer = this.project.renderers.find((r) => r.supports(ctx));
129 if (renderer) file.setRenderer(renderer);
130 }
131 }
132
133 /**
134 * Plans exports by analyzing all exported symbols.
135 *
136 * Registers re-export targets as files and creates new exported symbols for them.
137 *
138 * Assigns names to re-exported symbols and collects re-export metadata,
139 * distinguishing type-only exports based on symbol kinds.
140 */
141 private planExports(): void {
142 const seenByFile = new Map<File, Map<string, { kinds: Set<SymbolKind>; symbol: Symbol }>>();
143 const sourceFile = new Map<number, File>();
144
145 this.analyzer.analyze(this.project.nodes.all(), (ctx, node) => {
146 if (!node.exported) return;
147
148 const symbol = node.symbol;
149 if (!symbol) return;
150
151 const file = node.file;
152 if (!file) return;
153
154 for (const logicalFilePath of symbol.getExportFromFilePath?.(symbol) ?? []) {
155 const target = this.project.files.register({
156 external: false,
157 language: node.language,
158 logicalFilePath,
159 });
160 if (target.id === file.id) continue;
161
162 let fileMap = seenByFile.get(target);
163 if (!fileMap) {
164 fileMap = new Map();
165 seenByFile.set(target, fileMap);
166 }
167
168 const exp = this.project.symbols.register({
169 exported: true,
170 external: symbol.external,
171 importKind: symbol.importKind,
172 kind: symbol.kind,
173 name: symbol.finalName,
174 });
175 exp.setFile(target);
176 sourceFile.set(exp.id, file);
177 // TODO: pass node
178 this.assignTopLevelName({ ctx, symbol: exp });
179
180 let entry = fileMap.get(exp.finalName);
181 if (!entry) {
182 entry = { kinds: new Set(), symbol: exp };
183 fileMap.set(exp.finalName, entry);
184 }
185 entry.kinds.add(exp.kind);
186 }
187 });
188
189 for (const [file, fileMap] of seenByFile) {
190 const exports = new Map<File, ExportModule>();
191 for (const [, entry] of fileMap) {
192 const source = sourceFile.get(entry.symbol.id)!;
193 let exp = exports.get(source);
194 if (!exp) {
195 exp = {
196 canExportAll: true,
197 exports: [],
198 from: source,
199 isTypeOnly: true,
200 };
201 }
202 const isTypeOnly = [...entry.kinds].every((kind) => isTypeOnlyKind(kind));
203 const exportedName = entry.symbol.finalName;
204 exp.exports.push({
205 exportedName,
206 isTypeOnly,
207 kind: entry.symbol.importKind,
208 sourceName: entry.symbol.name,
209 });
210 if (entry.symbol.name !== entry.symbol.finalName) {
211 exp.canExportAll = false;
212 }
213 if (!isTypeOnly) {
214 exp.isTypeOnly = false;
215 }
216 exports.set(source, exp);
217 }
218 for (const [, exp] of exports) {
219 file.addExport(exp);
220 }
221 }
222 }
223
224 /**
225 * Plans imports by analyzing symbol dependencies across files.
226 *
227 * For external dependencies, assigns top-level names.
228 *
229 * Creates or reuses import symbols for dependencies from other files,
230 * assigning names and updating import metadata including type-only flags.
231 */
232 private planImports(): void {
233 const seenByFile = new Map<
234 File,
235 Map<
236 string,
237 {
238 dep: Symbol;
239 kinds: Set<SymbolKind>;
240 symbol: Symbol;
241 }
242 >
243 >();
244
245 this.analyzer.analyze(this.project.nodes.all(), (ctx) => {
246 const symbol = ctx.symbol;
247 if (!symbol) return;
248
249 const file = symbol.file;
250 if (!file) return;
251
252 let fileMap = seenByFile.get(file);
253 if (!fileMap) {
254 fileMap = new Map();
255 seenByFile.set(file, fileMap);
256 }
257
258 ctx.walkScopes((dependency) => {
259 const dep = fromRef(dependency);
260 if (!dep.file || dep.file.id === file.id) return;
261
262 if (dep.external) {
263 // TODO: pass node
264 this.assignTopLevelName({ ctx, symbol: dep });
265 }
266
267 const fromFileId = dep.file.id;
268 const importedName = dep.finalName;
269 const kind = dep.importKind;
270 const key = `${fromFileId}|${importedName}|${kind}`;
271
272 let entry = fileMap.get(key);
273 if (!entry) {
274 const imp = this.project.symbols.register({
275 exported: dep.exported,
276 external: dep.external,
277 importKind: dep.importKind,
278 kind: dep.kind,
279 name: dep.finalName,
280 });
281 imp.setFile(file);
282 // TODO: pass node
283 this.assignTopLevelName({
284 ctx,
285 scope: createScope({ localNames: imp.file!.allNames }),
286 symbol: imp,
287 });
288 entry = {
289 dep,
290 kinds: new Set(),
291 symbol: imp,
292 };
293 fileMap.set(key, entry);
294 }
295 entry.kinds.add(dep.kind);
296
297 dependency['~ref'] = entry.symbol;
298 });
299 });
300
301 for (const [file, fileMap] of seenByFile) {
302 const imports = new Map<File, ImportModule>();
303 for (const [, entry] of fileMap) {
304 const source = entry.dep.file!;
305 let imp = imports.get(source);
306 if (!imp) {
307 imp = {
308 from: source,
309 imports: [],
310 isTypeOnly: true,
311 kind: 'named',
312 };
313 }
314 const isTypeOnly = [...entry.kinds].every((kind) => isTypeOnlyKind(kind));
315 if (entry.symbol.importKind === 'namespace') {
316 imp.imports = [];
317 imp.kind = 'namespace';
318 imp.localName = entry.symbol.finalName;
319 } else if (entry.symbol.importKind === 'default') {
320 imp.kind = 'default';
321 imp.localName = entry.symbol.finalName;
322 } else {
323 imp.imports.push({
324 isTypeOnly,
325 localName: entry.symbol.finalName,
326 sourceName: entry.dep.finalName,
327 });
328 }
329 if (!isTypeOnly) {
330 imp.isTypeOnly = false;
331 }
332 imports.set(source, imp);
333 }
334 for (const [, imp] of imports) {
335 file.addImport(imp);
336 }
337 }
338 }
339
340 /**
341 * Assigns the final name to a top-level (file-scoped) symbol.
342 *
343 * Uses the symbol's file top-level names as the default scope,
344 * and updates all relevant name scopes including the file's allNames and local scopes.
345 *
346 * Supports optional overrides for the naming scope and scopes to update.
347 */
348 private assignTopLevelName(
349 args: Partial<AssignOptions> & {
350 ctx: AnalysisContext;
351 debug?: boolean;
352 node?: INode;
353 symbol: Symbol;
354 },
355 ): void {
356 if (!args.symbol.file) return;
357 this.assignSymbolName({
358 ...args,
359 file: args.symbol.file,
360 scope: args?.scope ?? createScope({ localNames: args.symbol.file.topLevelNames }),
361 scopesToUpdate: [
362 createScope({ localNames: args.symbol.file.allNames }),
363 args.ctx.scopes,
364 ...(args?.scopesToUpdate ?? []),
365 ],
366 });
367 }
368
369 /**
370 * Assigns the final name to a non-top-level (local) symbol.
371 *
372 * Uses the provided scope or derives it from the current analysis context's local names.
373 *
374 * Updates all provided name scopes accordingly.
375 */
376 private assignLocalName(
377 args: Pick<Partial<AssignOptions>, 'scope'> &
378 Pick<AssignOptions, 'scopesToUpdate'> & {
379 ctx: AnalysisContext;
380 debug?: boolean;
381 /** The file the symbol belongs to. */
382 file: File;
383 node?: INode;
384 symbol: Symbol;
385 },
386 ): void {
387 this.assignSymbolName({
388 ...args,
389 scope: args.scope ?? args.ctx.scope,
390 });
391 }
392
393 /**
394 * Assigns the final name to a symbol within the provided name scope.
395 *
396 * Resolves name conflicts until a unique name is found.
397 *
398 * Updates all specified name scopes with the assigned final name.
399 */
400 private assignSymbolName(
401 args: AssignOptions & {
402 ctx: AnalysisContext;
403 debug?: boolean;
404 /** The file the symbol belongs to. */
405 file: File;
406 node?: INode;
407 symbol: Symbol;
408 },
409 ): void {
410 const { ctx, file, node, scope, scopesToUpdate, symbol } = args;
411 if (this.cacheResolvedNames.has(symbol.id)) return;
412
413 const baseName = symbol.name;
414 let finalName =
415 node?.nameSanitizer?.(baseName) ?? symbol.node?.nameSanitizer?.(baseName) ?? baseName;
416 let attempt = 1;
417
418 const localNames = ctx.localNames(scope);
419 while (true) {
420 const kinds = [...(localNames.get(finalName) ?? [])];
421
422 const ok = kinds.every((kind) => canShareName(symbol.kind, kind));
423 if (ok) break;
424
425 const language = node?.language || symbol.node?.language || file.language;
426 const resolver =
427 (language ? this.project.nameConflictResolvers[language] : undefined) ??
428 this.project.defaultNameConflictResolver;
429 const resolvedName = resolver({ attempt, baseName });
430 if (!resolvedName) {
431 throw new Error(`Unresolvable name conflict: ${symbol.toString()}`);
432 }
433
434 finalName =
435 node?.nameSanitizer?.(resolvedName) ??
436 symbol.node?.nameSanitizer?.(resolvedName) ??
437 resolvedName;
438 attempt = attempt + 1;
439 }
440
441 symbol.setFinalName(finalName);
442 this.cacheResolvedNames.add(symbol.id);
443 const updateScopes = [scope, ...scopesToUpdate];
444 for (const scope of updateScopes) {
445 this.updateScope(symbol, scope);
446 }
447 }
448
449 /**
450 * Updates the provided name scope with the symbol's final name and kind.
451 *
452 * Ensures the name scope tracks all kinds associated with a given name.
453 */
454 private updateScope(symbol: Symbol, scope: Scope): void {
455 const name = symbol.finalName;
456 const cache = scope.localNames.get(name) ?? new Set();
457 cache.add(symbol.kind);
458 scope.localNames.set(name, cache);
459 }
460}