import { promises as fs } from 'fs'; import { compile } from 'handlebars'; import * as path from 'path'; import ts from 'typescript'; import * as ux from './ux'; type PackageError = { code: string; documentation: string }; type PackageErrors = { package: string; errors: PackageError[] }; const MONGOSH_ERRORS_DOC_TAG = 'mongoshErrors'; (async function () { const pathToPackages = path.resolve(process.argv[process.argv.length - 3]); const pathToMarkdownOutput = path.resolve( process.argv[process.argv.length - 2] ); const pathToRestructuredOutput = path.resolve( process.argv[process.argv.length - 1] ); if (!pathToPackages || !(await isDirectory(pathToPackages))) { ux.fatal('Could not find given packages directory:', pathToPackages); return; } const packageErrors: PackageErrors[] = []; const seenCodes = new Map(); let hasDuplicates = false; const packages = await collectPackages(pathToPackages); for (const packagePath of packages) { const dirName = path.basename(packagePath); ux.info(`Processing ${dirName}...`); const pe = await processPackage(packagePath); if (pe) { packageErrors.push(pe); ux.note(`Found ${pe.errors.length} mongosh errors.\n`); for (const e of pe.errors) { if (seenCodes.has(e.code)) { ux.error( `Duplicate code: ${e.code}\n`, `\tExisting documentation: ${ seenCodes.get(e.code)?.documentation }\n`, `\tNew documentation: ${e.documentation}\n` ); hasDuplicates = true; } else { seenCodes.set(e.code, e); } } } else { ux.quiet('No mongosh errors found.\n'); } } if (hasDuplicates) { ux.fatal('There were duplicate error codes, check output.'); return; } await renderErrorOverviewMarkdown(pathToMarkdownOutput, packageErrors); await renderErrorOverviewRestructured( pathToRestructuredOutput, packageErrors ); ux.success('👏👏👏👏'); ux.success('Wrote generated overview page:'); ux.success(` -> markdown: ${pathToMarkdownOutput}`); ux.success(` -> restructured: ${pathToRestructuredOutput}`); ux.success('👏👏👏👏'); })().catch((err) => process.nextTick(() => { throw err; }) ); async function isDirectory(path: string): Promise { try { const stat = await fs.lstat(path); return stat.isDirectory(); } catch (e: any) { return false; } } async function isFile(path: string): Promise { try { const stat = await fs.lstat(path); return stat.isFile(); } catch (e: any) { return false; } } async function collectPackages(pathToPackages: string): Promise { const dirs = await fs.readdir(pathToPackages); const packages = await Promise.all( dirs.map(async (dir) => { const packageJsonPath = path.resolve(pathToPackages, dir, 'package.json'); const tsconfigPath = path.resolve(pathToPackages, dir, 'tsconfig.json'); if ((await isFile(tsconfigPath)) && (await isFile(packageJsonPath))) { return path.resolve(pathToPackages, dir); } return undefined; }) ); return packages.filter((d) => !!d) as string[]; } async function processPackage( pathToPackage: string ): Promise { const packageJsonContent = await fs.readFile( path.resolve(pathToPackage, 'package.json'), { encoding: 'utf-8' } ); const packageName = (JSON.parse(packageJsonContent) as any).name; const tsProgram = await createTsProgram(pathToPackage); if (!tsProgram) { return undefined; } const errors: PackageError[] = await extractErrors(pathToPackage, tsProgram); if (!errors || !errors.length) { return undefined; } return { package: packageName, errors, }; } // eslint-disable-next-line @typescript-eslint/require-await async function createTsProgram( pathToPackage: string ): Promise { const tsconfigPath = ts.findConfigFile(pathToPackage, ts.sys.fileExists); if (!tsconfigPath) { ux.error(`Could not locate tsconfig.json in ${pathToPackage}`); return undefined; } const tsconfigContent = ts.readJsonConfigFile(tsconfigPath, ts.sys.readFile); const tsconfig = ts.parseJsonConfigFileContent( tsconfigContent, ts.sys, path.resolve(pathToPackage) ); return ts.createProgram({ options: tsconfig.options, rootNames: tsconfig.fileNames, }); } // eslint-disable-next-line @typescript-eslint/require-await async function extractErrors( pathToPackage: string, program: ts.Program ): Promise { const errors: PackageError[] = []; const checker = program.getTypeChecker(); program .getSourceFiles() .filter((sf) => isRelevantSourceFileInPackage(pathToPackage, sf)) .forEach((sf) => ts.forEachChild(sf, visit)); function visit(node: ts.Node): void { const enumData = tryExtractMongoshErrorsEnumDeclaration(checker, node); if (!enumData) { return; } const { enumName, enumDeclaration } = enumData; enumDeclaration.members.forEach((m) => { const memberName = m.name.getText(); if (!m.initializer || !ts.isStringLiteral(m.initializer)) { ux.error( `Enum value ${enumName}.${memberName} must have a string literal as initializer` ); return; } const memberSymbol = checker.getSymbolAtLocation(m.name); const memberDoc = ts.displayPartsToString( memberSymbol?.getDocumentationComment(checker) || [] ); const memberValue = m.initializer.text; errors.push({ code: memberValue, documentation: memberDoc, }); }); } return errors; } function isRelevantSourceFileInPackage( pathToPackage: string, sf: ts.SourceFile ): boolean { if (sf.isDeclarationFile) { return false; } return path.resolve(sf.fileName).startsWith(path.resolve(pathToPackage)); } function tryExtractMongoshErrorsEnumDeclaration( checker: ts.TypeChecker, node: ts.Node ): { enumName: string; enumDeclaration: ts.EnumDeclaration } | undefined { if (!ts.isEnumDeclaration(node)) { return undefined; } const enumSymbol = checker.getSymbolAtLocation(node.name); if (!enumSymbol) { ux.warning( 'Found enum but could not correlate with symbol:', node.name.getText() ); return undefined; } const docTags = enumSymbol.getJsDocTags(); if (!docTags.find((t) => t.name === MONGOSH_ERRORS_DOC_TAG)) { return undefined; } const enumName = enumSymbol.getName(); const enumDeclaration = enumSymbol.valueDeclaration as ts.EnumDeclaration; return { enumName, enumDeclaration }; } async function renderErrorOverviewMarkdown( outputPath: string, packageErrors: PackageErrors[] ): Promise { const templateContent = await fs.readFile( path.resolve(__dirname, 'error-overview.tmpl.md'), { encoding: 'utf-8' } ); const template = compile(templateContent); const output = template({ packages: packageErrors, }); await fs.writeFile(outputPath, output, { encoding: 'utf-8' }); } async function renderErrorOverviewRestructured( outputPath: string, packageErrors: PackageErrors[] ): Promise { const restructuredErrors = packageErrors.map((pe) => { return { package: pe.package, packageHeadlineSeparator: '-'.repeat(pe.package.length), errors: pe.errors.map(convertMarkdownToRestructured), }; }); const templateContent = await fs.readFile( path.resolve(__dirname, 'error-overview.tmpl.rst'), { encoding: 'utf-8' } ); const template = compile(templateContent); const output = template({ packages: restructuredErrors, }); await fs.writeFile(outputPath, output, { encoding: 'utf-8' }); } function convertMarkdownToRestructured( error: PackageError ): PackageError & { codeHeadlineSeparator: string } { // every odd element is _inside_ a code block (```) const docWithCodeBlocks = error.documentation.split('```'); for (let i = 0; i < docWithCodeBlocks.length; i++) { const text = docWithCodeBlocks[i]; let restructured = ''; if (i % 2 === 1) { // we have a code block - prefix and indent const codeLines = text.split('\n'); restructured += '.. code-block:: ' + codeLines.shift() + '\n\n'; restructured += codeLines.map((l) => ' ' + l).join('\n'); } else { const inlineCode = /`([^`]*)`/g; restructured = text.replace(inlineCode, (_, code) => '``' + code + '``'); } docWithCodeBlocks[i] = restructured; } return { code: error.code, codeHeadlineSeparator: '~'.repeat(error.code.length + 4), documentation: docWithCodeBlocks.join('\n'), }; }