import fs from 'fs'; import path from 'path'; import minimatch from 'minimatch'; import { Command } from 'commander'; import { compile, Meta as M } from '@preserves/schema'; import chalk from 'chalk'; import { is, Position } from '@preserves/core'; import chokidar from 'chokidar'; import { changeExt, Diagnostic, inputToInputGlob, expandInputGlob, formatFailures } from './cli-utils'; export type CommandLineArguments = { inputs: string[]; xrefs: string[]; output: string | undefined; stdout: boolean; core: string; watch: boolean; traceback: boolean; module: string[]; }; export type CompilationResult = { options: CommandLineArguments, inputFiles: Array, failures: Array, baseDirs: Array, }; export type TranslatedFile = { inputBaseDir: string, inputFilePath: string, outputFilePath: string, schemaPath: M.ModulePath, schema: M.Schema, }; function failureCount(type: 'warn' | 'error', r: CompilationResult): number { return r.failures.filter(f => f.type === type).length; } export function run(options: CommandLineArguments): void { if (!options.watch) { if (failureCount('error', runOnce(options)) > 0) { process.exit(1); } } else { function runWatch() { console.clear(); console.log(chalk.gray(new Date().toISOString()) + ' Compiling Schemas in watch mode...\n'); const r = runOnce(options); const warningCount = failureCount('warn', r); const errorCount = failureCount('error', r); const wMsg = (warningCount > 0) && chalk.yellowBright(`${warningCount} warning(s)`); const eMsg = (errorCount > 0) && chalk.redBright(`${errorCount} error(s)`); const errorSummary = (wMsg && eMsg) ? `with ${eMsg} and ${wMsg}` : (wMsg) ? `with ${wMsg}` : (eMsg) ? `with ${eMsg}` : chalk.greenBright('successfully'); console.log(chalk.gray(new Date().toISOString()) + ` Processed ${r.inputFiles.length} file(s) ${errorSummary}. Waiting for changes.`); let triggered = false; const watchers = r.baseDirs.map(base => chokidar.watch(base, { ignoreInitial: true, }).on('all', (_event, filename) => { const relevant = options.inputs.some(i => { const { baseDir, glob } = inputToInputGlob(i); return minimatch(filename, baseDir + glob); }); if (relevant) { if (!triggered) { triggered = true; watchers.map(w => w.close()); runWatch(); } } })); } runWatch(); } } export function modulePathTo(file1: string, file2: string): string { let naive = path.relative(path.dirname(file1), file2); if (naive[0] !== '.' && naive[0] !== '/') naive = './' + naive; return changeExt(naive, ''); } function isAbsoluteOrExplicitlyRelative(p: string) { return p[0] === '.' || p[0] === '/'; } export function runOnce(options: CommandLineArguments): CompilationResult { const { baseDirs, failures, inputFiles: inputFiles0, xrefFiles: xrefFiles0 } = expandInputGlob(options.inputs, options.xrefs); let xrefFiles = xrefFiles0; // filtered during construction of extensionEnv const extensionEnv: M.Environment = options.module.map(arg => { const i = arg.indexOf('='); if (i === -1) throw new Error(`--module argument must be Namespace=path or Namespace=path:expr; got ${arg}`); const ns = arg.slice(0, i); const pathAndExpr = arg.slice(i + 1); const j = pathAndExpr.lastIndexOf(':'); const path = (j === -1) ? pathAndExpr : pathAndExpr.slice(0, j); const modulePath = ns.split('.').map(Symbol.for); const expr = (j === -1) ? null : pathAndExpr.slice(j + 1); const e = xrefFiles.find(x => is(x.modulePath, modulePath)); if (e) xrefFiles = xrefFiles.filter(e0 => !Object.is(e0, e)); return { schema: e ? e.schema : null, schemaModulePath: modulePath, typescriptModulePath: path, typescriptModuleExpr: expr, }; }); const inputFiles: Array = inputFiles0.map(i => { const { inputBaseDir, inputFilePath, baseRelPath, modulePath, schema } = i; const outputFilePath = path.join(options.output ?? '.', changeExt(baseRelPath, '.ts')); return { inputBaseDir, inputFilePath, outputFilePath, schemaPath: modulePath, schema }; }); inputFiles.forEach(c => { const env: M.Environment = [ ... extensionEnv.flatMap(e => { const p = isAbsoluteOrExplicitlyRelative(e.typescriptModulePath) ? modulePathTo(c.outputFilePath, e.typescriptModulePath) : e.typescriptModulePath /* assuming it names something in node_modules */; if (p === null) return []; return [{... e, typescriptModulePath: p}]; }), ... xrefFiles.map(x => ({ schema: x.schema, schemaModulePath: x.modulePath, typescriptModulePath: 'xref-typescript-modules-not-available', typescriptModuleExpr: null, })), ... inputFiles.map(cc => ({ schema: cc.schema, schemaModulePath: cc.schemaPath, typescriptModulePath: modulePathTo(c.outputFilePath, cc.outputFilePath), typescriptModuleExpr: null, })), ]; fs.mkdirSync(path.dirname(c.outputFilePath), { recursive: true }); let compiledModule; try { compiledModule = compile(env, c.schemaPath, c.schema, { preservesModule: options.core, warn: (message: string, pos: Position | null) => failures.push({ type: 'warn', file: c.inputFilePath, detail: { message, pos } }), }); } catch (e) { failures.push({ type: 'error', file: c.inputFilePath, detail: e as Error }); } if (compiledModule !== void 0) { if (options.stdout) { console.log('////------------------------------------------------------------'); console.log('//// ' + c.outputFilePath); console.log(); console.log(compiledModule); } else { fs.writeFileSync(c.outputFilePath, compiledModule, 'utf-8'); } } }); formatFailures(failures, options.traceback); return { options, inputFiles, failures, baseDirs }; } export function main(argv: Array) { new Command() .arguments('[input...]') .description('Compile Preserves schema definitions to TypeScript', { input: 'Input directory, optionally with : on the end', }) .option('--xref ', 'Cross-reference other textual Preserves schema definitions', (glob, prev: string[]) => [... prev, glob], []) .option('--output ', 'Output directory for modules') .option('--stdout', 'Prints each module to stdout one after the other instead ' + 'of writing them to files in the `--output` directory') .option('--core ', 'Import path for @preserves/core', '@preserves/core') .option('--watch', 'Watch base directory for changes') .option('--traceback', 'Include stack traces in compiler errors') .option('--module ', 'Additional Namespace=path import', (nsPath: string, previous: string[]): string[] => [... previous, nsPath], []) .action((inputs: string[], rawOptions) => { if ((rawOptions.output === void 0 && !rawOptions.stdout) || (rawOptions.output !== void 0 && rawOptions.stdout)) { throw new Error("Either --output or --stdout (but not both) must be supplied."); } const options: CommandLineArguments = { inputs: inputs.map(i => path.normalize(i)), xrefs: rawOptions.xref.map((x: string) => path.normalize(x)), output: rawOptions.output, stdout: rawOptions.stdout, core: rawOptions.core, watch: rawOptions.watch, traceback: rawOptions.traceback, module: rawOptions.module, }; Error.stackTraceLimit = Infinity; run(options); }) .parse(argv, { from: 'user' }); }