taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

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 });