fork of hey-api/openapi-ts because I need some additional things
at feat/skip-token 460 lines 14 kB view raw
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}