|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +import {open, opendir, stat} from 'node:fs/promises'; |
| 4 | + |
| 5 | +import {parse, print} from 'recast'; |
| 6 | +import typeScriptParser from 'recast/parsers/typescript.js'; |
| 7 | + |
| 8 | +const options = {dryRun: false, extensions: ['.d.ts'], packages: new Set(), verbose: false}; |
| 9 | + |
| 10 | +/** |
| 11 | + * Recursively discovers matching files and directories in the given path and corrects any |
| 12 | + * unqualified exports and imports by appending the appropriate file names and extensions. |
| 13 | + */ |
| 14 | +async function processFiles(rootPath) { |
| 15 | + for await (const directoryEntry of await opendir(rootPath)) { |
| 16 | + /** Whether the current file contained exports/imports that have been corrected. */ |
| 17 | + let hasFileBeenCorrected = false; |
| 18 | + /** Whether the path to the current file has so far been logged. */ |
| 19 | + let hasPathBeenPrinted = false; |
| 20 | + /** Absolute path to the current directory or file. */ |
| 21 | + const path = `${rootPath}/${directoryEntry.name}`; |
| 22 | + |
| 23 | + /** |
| 24 | + * @param {string} message |
| 25 | + * @param {'error' | 'info'} severity |
| 26 | + */ |
| 27 | + const log = (message, severity = 'info') => { |
| 28 | + if (severity == 'info' && !options.verbose) return; |
| 29 | + |
| 30 | + if (!hasPathBeenPrinted) { |
| 31 | + hasPathBeenPrinted = true; |
| 32 | + console.log(path); |
| 33 | + } |
| 34 | + |
| 35 | + (severity == 'error' ? console.error : console.log)('\t', message); |
| 36 | + }; |
| 37 | + |
| 38 | + if (directoryEntry.isDirectory() && directoryEntry.name != 'node_modules') { |
| 39 | + await processFiles(path); |
| 40 | + } else if (options.extensions.some((extension) => directoryEntry.name.endsWith(extension))) { |
| 41 | + const file = await open(path, 'r+'); |
| 42 | + |
| 43 | + try { |
| 44 | + const ast = parse((await file.readFile()).toString(), {parser: typeScriptParser}); |
| 45 | + |
| 46 | + for (const node of ast.program.body) { |
| 47 | + if ((node.type.startsWith('Export') || node.type.startsWith('Import')) |
| 48 | + && node.source?.value.startsWith('.')) { |
| 49 | + const unqualifiedPath = node.source.value; |
| 50 | + |
| 51 | + /** @type {Awaited<ReturnType<stat>>} */ |
| 52 | + let stats; |
| 53 | + for (const suffix of ['', '/index.js', '.js']) { |
| 54 | + try { |
| 55 | + const qualifiedPath = `${unqualifiedPath}${suffix}`; |
| 56 | + stats = await stat(`${rootPath}/${qualifiedPath}`); |
| 57 | + if (!stats.isFile() /* is directory */) continue; // try next suffix |
| 58 | + |
| 59 | + if (suffix) { // needs qualification |
| 60 | + log(`🛠️ ${unqualifiedPath} → ${qualifiedPath}`); |
| 61 | + node.source.value = qualifiedPath; |
| 62 | + hasFileBeenCorrected = true; |
| 63 | + } else { // already fully qualified |
| 64 | + log(`✔️ ${qualifiedPath}`); |
| 65 | + } |
| 66 | + break; |
| 67 | + } catch (error) { |
| 68 | + if (error.code == 'ENOENT' /* no such file or directory */) continue; // try next suffix |
| 69 | + throw error; |
| 70 | + } |
| 71 | + } |
| 72 | + if (!stats) log(`❌ ${unqualifiedPath}`, 'error'); |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + // write modified file |
| 77 | + if (hasFileBeenCorrected && !options.dryRun) { |
| 78 | + await file.truncate(); |
| 79 | + await file.write(print(ast, {quote: 'single'}).code, 0); |
| 80 | + } |
| 81 | + } finally { |
| 82 | + await file.close(); |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +for (const argument of process.argv.slice(2)) { |
| 89 | + switch (argument) { |
| 90 | + case '--dry-run': options.dryRun = options.verbose = true; break; |
| 91 | + case '--verbose': options.verbose = true; break; |
| 92 | + default: |
| 93 | + if (argument.startsWith('--extensions=')) { |
| 94 | + const extensions = argument.split('=')[1].split(',').filter((extension) => Boolean(extension.trim())); |
| 95 | + if (extensions.length) options.extensions = extensions; |
| 96 | + } else if (!argument.startsWith('--') && argument.match(/^[^._][^~)('!*]{1,214}$/)) { |
| 97 | + options.packages.add(argument); |
| 98 | + } |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +if (options.packages.size) { |
| 103 | + options.packages.forEach(async (packageName) => await processFiles(`./node_modules/${packageName}`)); |
| 104 | +} else { |
| 105 | + console.log(`npx renova [--dry-run] [--extensions=<extension>,...] [--verbose] <package> ... |
| 106 | +
|
| 107 | + <package>: Name of a package under ./node_modules to patch, e.g. '@apollo/client'. |
| 108 | +
|
| 109 | + --dry-run: Print potential outcome without altering files. Implies --verbose. |
| 110 | + --extensions: Comma-separated list of file name extensions to process. Defaults to '.d.ts'. |
| 111 | + --verbose: Print all matching exports and imports.`); |
| 112 | +} |
0 commit comments