import { compile, readSchema } from '../index'; import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import minimatch from 'minimatch'; import yargs from 'yargs/yargs'; import * as M from '../meta'; import chalk from 'chalk'; import { formatPosition, Position } from '@preserves/core'; export type CommandLineArguments = { input: string; base: string | undefined; output: string | undefined; core: string; watch: boolean; traceback: boolean; module: string[]; }; export interface Diagnostic { type: 'warn' | 'error'; file: string; detail: Error | { message: string, pos: Position | null }; }; export type CompilationResult = { options: CommandLineArguments, inputFiles: Array, failures: Array, base: string, output: string, }; export type InputFile = { inputFilePath: string, outputFilePath: string, schemaPath: M.ModulePath, schema: M.Schema, }; export function computeBase(paths: string[]): string { if (paths.length === 0) { return ''; } else if (paths.length === 1) { return path.dirname(paths[0]) + '/'; } else { let i = 0; while (true) { let ch: string | null = null for (const p of paths) { if (i >= p.length) return p.slice(0, i); if (ch === null) ch = p[i]; if (p[i] !== ch) return p.slice(0, i); } i++; } } } 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.`); const watcher = fs.watch(r.base, { recursive: true }, (_event, filename) => { filename = path.join(r.base, filename); if (minimatch(filename, options.input)) { watcher.close(); runWatch(); } }); } runWatch(); } } function changeExt(p: string, newext: string): string { return p.slice(0, -path.extname(p).length) + newext; } export function modulePathTo(file1: string, file2: string): string | null { if (file1 === file2) return null; let naive = path.relative(path.dirname(file1), file2); if (naive[0] !== '.' && naive[0] !== '/') naive = './' + naive; return changeExt(naive, ''); } export function runOnce(options: CommandLineArguments): CompilationResult { const matches = glob.sync(options.input); const failures: Array = []; const base = options.base ?? computeBase(matches); const output = options.output ?? base; const extensionEnv: M.Environment = options.module.map(arg => { const i = arg.indexOf('='); if (i === -1) throw new Error(`--module argument must be Namespace=path: ${arg}`); const ns = arg.slice(0, i); const path = arg.slice(i + 1); return { schema: null, schemaModulePath: ns.split('.').map(Symbol.for), typescriptModulePath: path, }; }); const inputFiles: Array = matches.flatMap(inputFilePath => { if (!inputFilePath.startsWith(base)) { throw new Error(`Input filename ${inputFilePath} falls outside base ${base}`); } const relPath = inputFilePath.slice(base.length); const outputFilePath = path.join(output, changeExt(relPath, '.ts')); try { const src = fs.readFileSync(inputFilePath, 'utf-8'); const schema = readSchema(src, { name: inputFilePath }); const schemaPath = relPath.split('/').map(p => p.split('.')[0]).map(Symbol.for); return [{ inputFilePath, outputFilePath, schemaPath, schema }]; } catch (e) { failures.push({ type: 'error', file: inputFilePath, detail: e }); return []; } }); inputFiles.forEach(c => { const env: M.Environment = [ ... extensionEnv.flatMap(e => { if (e.typescriptModulePath === null) return []; const p = modulePathTo(c.outputFilePath, e.typescriptModulePath); if (p === null) return []; return [{... e, typescriptModulePath: p}]; }), ... inputFiles.map(cc => ({ schema: cc.schema, schemaModulePath: cc.schemaPath, typescriptModulePath: modulePathTo(c.outputFilePath, cc.outputFilePath), })), ]; fs.mkdirSync(path.dirname(c.outputFilePath), { recursive: true }); try { fs.writeFileSync(c.outputFilePath, compile(env, c.schema, { preservesModule: options.core, warn: (message: string, pos: Position | null) => failures.push({ type: 'warn', file: c.inputFilePath, detail: { message, pos } }), }), 'utf-8'); } catch (e) { failures.push({ type: 'error', file: c.inputFilePath, detail: e }); } }); for (const d of failures) { console.log( (d.type === 'error' ? chalk.redBright('[ERROR]') : chalk.yellowBright('[WARNING]')) + ' ' + chalk.blueBright(formatPosition((d.detail as any).pos ?? d.file)) + ': ' + d.detail.message + (options.traceback && (d.detail instanceof Error) ? '\n' + d.detail.stack : '')); } if (failures.length > 0) { console.log(); } return { options, inputFiles, failures, base, output }; } export function main(argv: Array) { const options: CommandLineArguments = yargs(argv) .command('$0 ', 'Compile Preserves schema definitions to TypeScript', yargs => yargs .positional('input', { type: 'string', description: 'Input filename or glob', demandOption: true, }) .option('output', { type: 'string', description: 'Output directory for sources (default: next to sources)', }) .option('base', { type: 'string', description: 'Base directory for sources (default: common prefix)', }) .option('core', { type: 'string', description: 'Import path for @preserves/core', default: '@preserves/core', }) .option('watch', { type: 'boolean', descripion: 'Watch base directory for changes', default: false, }) .option('traceback', { type: 'boolean', description: 'Include stack traces in compiler errors', default: false, }) .option('module', { type: 'string', array: true, description: 'Additional Namespace=path imports', default: [], }), argv => argv) .argv; options.input = path.normalize(options.input); run(options); }