extract.ts (11747B)
1 /* 2 This file is part of GNU Taler 3 (C) 2022 Taler Systems S.A. 4 5 GNU Taler is free software; you can redistribute it and/or modify it under the 6 terms of the GNU General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 13 You should have received a copy of the GNU General Public License along with 14 GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 import * as ts from "typescript"; 18 import * as fs from "fs/promises"; 19 import * as path from "path"; 20 import * as prettier from "prettier"; 21 22 if (process.argv.length !== 4) { 23 console.error( 24 `usage: ${process.argv[0]} ${process.argv[1]} TALER_TYPESCRIPT_CORE_REPO OUTFILE`, 25 ); 26 process.exit(2); 27 } 28 29 const walletRootDir = path.resolve(process.argv[2]); 30 const outfile = path.resolve(process.argv[3]); 31 32 const walletCoreDir = path.join(walletRootDir, "packages", "taler-wallet-core"); 33 const excludedNames = new Set([ 34 "TalerErrorCode", 35 "WalletBackupContentV1", 36 "Array", 37 ]); 38 39 const configFile = ts.findConfigFile( 40 walletCoreDir, 41 ts.sys.fileExists, 42 "tsconfig.json", 43 ); 44 if (!configFile) { 45 throw Error(`tsconfig.json not found below ${walletCoreDir}`); 46 } 47 48 const configResult = ts.readConfigFile(configFile, ts.sys.readFile); 49 if (configResult.error) { 50 throw Error(formatDiagnostics([configResult.error])); 51 } 52 53 const parsedConfig = ts.parseJsonConfigFileContent( 54 configResult.config, 55 ts.sys, 56 path.dirname(configFile), 57 undefined, 58 configFile, 59 ); 60 if (parsedConfig.errors.length > 0) { 61 throw Error(formatDiagnostics(parsedConfig.errors)); 62 } 63 64 const program = ts.createProgram({ 65 options: parsedConfig.options, 66 rootNames: parsedConfig.fileNames, 67 projectReferences: parsedConfig.projectReferences, 68 }); 69 const syntaxDiagnostics = program.getSyntacticDiagnostics(); 70 if (syntaxDiagnostics.length > 0) { 71 throw Error(formatDiagnostics(syntaxDiagnostics)); 72 } 73 const checker = program.getTypeChecker(); 74 const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 75 76 const walletApiTypesFile = path.join( 77 walletCoreDir, 78 "src", 79 "wallet-api-types.ts", 80 ); 81 const sourceFile = program.getSourceFile(walletApiTypesFile); 82 if (!sourceFile) { 83 throw Error( 84 `TypeScript source file is not part of the program: ${walletApiTypesFile}`, 85 ); 86 } 87 88 const fileSymbol = checker.getSymbolAtLocation(sourceFile); 89 if (!fileSymbol?.exports) { 90 throw Error(`Could not read exports from ${walletApiTypesFile}`); 91 } 92 const exportedSymbols: ts.SymbolTable = fileSymbol.exports; 93 94 interface PerOpGatherState { 95 opName: string; 96 visitedSymbols: Set<ts.Symbol>; 97 declarationSymbols: Set<ts.Symbol>; 98 group: string; 99 /** Enum member declaration in the form 'Foo = "bar"'. */ 100 enumMemberDecl: string | undefined; 101 } 102 103 interface GatherState { 104 declTexts: Map<ts.Symbol, string>; 105 declNames: Map<ts.Symbol, string>; 106 } 107 108 function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { 109 return ts.formatDiagnostics(diagnostics, { 110 getCanonicalFileName: (fileName) => fileName, 111 getCurrentDirectory: ts.sys.getCurrentDirectory, 112 getNewLine: () => ts.sys.newLine, 113 }); 114 } 115 116 function isDefaultLibraryDeclaration(decl: ts.Declaration): boolean { 117 const source = decl.getSourceFile(); 118 return ( 119 source.hasNoDefaultLib || 120 (source.isDeclarationFile && path.basename(source.fileName).startsWith("lib.")) 121 ); 122 } 123 124 function isDocumentedDeclaration( 125 decl: ts.Declaration, 126 ): decl is 127 | ts.InterfaceDeclaration 128 | ts.EnumDeclaration 129 | ts.TypeAliasDeclaration 130 | ts.ClassDeclaration { 131 return ( 132 ts.isInterfaceDeclaration(decl) || 133 ts.isEnumDeclaration(decl) || 134 ts.isTypeAliasDeclaration(decl) || 135 ts.isClassDeclaration(decl) 136 ); 137 } 138 139 function resolveAlias(symbol: ts.Symbol): ts.Symbol { 140 if (symbol.flags & ts.SymbolFlags.Alias) { 141 return checker.getAliasedSymbol(symbol); 142 } 143 return symbol; 144 } 145 146 /** 147 * Enum-member references such as WalletApiOperation.InitWallet need the enum 148 * declaration, not a non-standalone EnumMember snippet. 149 */ 150 function declarationOwner(symbol: ts.Symbol): ts.Symbol { 151 const resolved = resolveAlias(symbol); 152 const declarations = resolved.getDeclarations(); 153 if (!declarations?.some(ts.isEnumMember)) { 154 return resolved; 155 } 156 const enumMember = declarations.find(ts.isEnumMember); 157 if (!enumMember) { 158 return resolved; 159 } 160 return checker.getSymbolAtLocation(enumMember.parent.name) ?? resolved; 161 } 162 163 function printableDeclarations(symbol: ts.Symbol): ts.Declaration[] { 164 return (symbol.getDeclarations() ?? []).filter( 165 (decl) => isDocumentedDeclaration(decl) && !isDefaultLibraryDeclaration(decl), 166 ); 167 } 168 169 /** 170 * Gather declarations referenced by a declaration. Resolve identifiers to 171 * their symbols instead of asking for the type of every syntax node: the latter 172 * mistakes property names for dependencies and repeatedly walks large runtime 173 * declarations that can never be emitted as TypeScript type definitions. 174 */ 175 function gatherDecls( 176 node: ts.Node, 177 gatherState: GatherState, 178 perOpState: PerOpGatherState, 179 ): void { 180 if (ts.isIdentifier(node)) { 181 const referenced = checker.getSymbolAtLocation(node); 182 if (referenced) { 183 const symbol = declarationOwner(referenced); 184 const name = symbol.getName(); 185 if (!perOpState.visitedSymbols.has(symbol)) { 186 perOpState.visitedSymbols.add(symbol); 187 if (!excludedNames.has(name)) { 188 const declarations = printableDeclarations(symbol); 189 if (declarations.length > 0) { 190 perOpState.declarationSymbols.add(symbol); 191 const text = declarations 192 .map((decl) => 193 printer.printNode(ts.EmitHint.Unspecified, decl, decl.getSourceFile()), 194 ) 195 .join("\n"); 196 gatherState.declTexts.set(symbol, text); 197 gatherState.declNames.set(symbol, name); 198 for (const decl of declarations) { 199 gatherDecls(decl, gatherState, perOpState); 200 } 201 } 202 } 203 } 204 } 205 } 206 node.forEachChild((child) => gatherDecls(child, gatherState, perOpState)); 207 } 208 209 function getOpEnumDecl(decl: ts.Declaration): string | undefined { 210 let enumMemberDecl: string | undefined; 211 function walk(node: ts.Node): void { 212 if (enumMemberDecl) { 213 return; 214 } 215 if (ts.isPropertySignature(node) && node.name.getText() === "op" && node.type) { 216 let symbol: ts.Symbol | undefined; 217 if (ts.isTypeReferenceNode(node.type)) { 218 symbol = checker.getSymbolAtLocation(node.type.typeName); 219 } 220 const member = symbol 221 ?.getDeclarations() 222 ?.find((candidate): candidate is ts.EnumMember => ts.isEnumMember(candidate)); 223 if (member) { 224 enumMemberDecl = member.getText(); 225 return; 226 } 227 } 228 node.forEachChild(walk); 229 } 230 walk(decl); 231 return enumMemberDecl; 232 } 233 234 function groupFromLeadingComments(decl: ts.Declaration): string | undefined { 235 const source = decl.getSourceFile(); 236 const ranges = ts.getLeadingCommentRanges(source.getFullText(), decl.getFullStart()); 237 for (const range of ranges ?? []) { 238 const comment = source.getFullText().slice(range.pos, range.end); 239 const match = /\bgroup:\s*([^\r\n*]+)/.exec(comment); 240 if (match) { 241 return match[1].trim(); 242 } 243 } 244 return undefined; 245 } 246 247 function removeGroupComment(text: string): string { 248 return text.replace(/^\s*\/\/\s*group:[^\r\n]*(?:\r?\n)?/m, ""); 249 } 250 251 function renderDeclaration(name: string, text: string): string { 252 const formatted = prettier.format(text, { 253 semi: true, 254 parser: "typescript", 255 }); 256 return `\`\`\`{ts:def} ${name}\n${formatted.trimEnd()}\n\`\`\`\n`; 257 } 258 259 async function main(): Promise<void> { 260 const gatherState: GatherState = { 261 declTexts: new Map(), 262 declNames: new Map(), 263 }; 264 const perOpStates: PerOpGatherState[] = []; 265 let currentGroup = "Unknown Group"; 266 267 exportedSymbols.forEach((exportedSymbol) => { 268 if (!exportedSymbol.name.endsWith("Op")) { 269 return; 270 } 271 const symbol = declarationOwner(exportedSymbol); 272 const declarations = printableDeclarations(symbol); 273 if (declarations.length === 0) { 274 return; 275 } 276 currentGroup = 277 declarations 278 .map(groupFromLeadingComments) 279 .find((group): group is string => group !== undefined) ?? currentGroup; 280 const perOpState: PerOpGatherState = { 281 opName: exportedSymbol.name, 282 visitedSymbols: new Set([symbol]), 283 declarationSymbols: new Set([symbol]), 284 group: currentGroup, 285 enumMemberDecl: declarations 286 .map(getOpEnumDecl) 287 .find((member): member is string => member !== undefined), 288 }; 289 let declText = declarations 290 .map((decl) => 291 printer.printNode(ts.EmitHint.Unspecified, decl, decl.getSourceFile()), 292 ) 293 .join("\n"); 294 if (perOpState.enumMemberDecl) { 295 declText += `\n// ${perOpState.enumMemberDecl}\n`; 296 } 297 gatherState.declTexts.set(symbol, removeGroupComment(declText)); 298 gatherState.declNames.set(symbol, exportedSymbol.name); 299 for (const decl of declarations) { 300 gatherDecls(decl, gatherState, perOpState); 301 } 302 perOpStates.push(perOpState); 303 }); 304 305 const symbolsByName = new Map<string, ts.Symbol>(); 306 for (const [symbol, name] of gatherState.declNames) { 307 const previous = symbolsByName.get(name); 308 if (previous && previous !== symbol) { 309 throw Error(`Cannot emit two different TypeScript declarations named ${name}`); 310 } 311 symbolsByName.set(name, symbol); 312 } 313 314 const symbolUseCounts = new Map<ts.Symbol, number>(); 315 for (const operation of perOpStates) { 316 for (const symbol of operation.declarationSymbols) { 317 symbolUseCounts.set(symbol, (symbolUseCounts.get(symbol) ?? 0) + 1); 318 } 319 } 320 const commonSymbols = new Set( 321 [...symbolUseCounts] 322 .filter(([, count]) => count > 1) 323 .map(([symbol]) => symbol), 324 ); 325 326 const groups = new Set(perOpStates.map((operation) => operation.group)); 327 const output: string[] = [ 328 "# Wallet-Core API Documentation\n", 329 "This file is auto-generated from the [taler-typescript-core](https://git.taler.net/taler-typescript-core.git/tree/packages/taler-wallet-core/src/wallet-api-types.ts) repository.\n", 330 "## Overview\n", 331 ]; 332 for (const group of groups) { 333 output.push(`### ${group}\n`); 334 for (const operation of perOpStates) { 335 if (operation.group === group) { 336 output.push(`* [${operation.opName}](#${operation.opName.toLowerCase()})\n`); 337 } 338 } 339 } 340 341 output.push("## Operation Reference\n"); 342 for (const operation of perOpStates) { 343 output.push(`### ${operation.opName}\n`); 344 for (const symbol of operation.declarationSymbols) { 345 if (commonSymbols.has(symbol)) { 346 continue; 347 } 348 const text = gatherState.declTexts.get(symbol); 349 const name = gatherState.declNames.get(symbol); 350 if (text && name) { 351 output.push(renderDeclaration(name, text)); 352 } 353 } 354 output.push("\n"); 355 } 356 357 output.push("## Common Declarations\n"); 358 for (const symbol of commonSymbols) { 359 const text = gatherState.declTexts.get(symbol); 360 const name = gatherState.declNames.get(symbol); 361 if (text && name) { 362 output.push(renderDeclaration(name, text)); 363 } 364 } 365 366 await fs.writeFile(outfile, output.join("")); 367 console.log( 368 `Wrote ${gatherState.declTexts.size} declarations for ${perOpStates.length} operations to ${outfile}`, 369 ); 370 } 371 372 main().catch((error: unknown) => { 373 console.error(error); 374 process.exitCode = 1; 375 });