preserves/implementations/javascript/packages/schema/src/bin/preserves-schema-ts.ts

234 lines
8.4 KiB
TypeScript

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<InputFile>,
failures: Array<Diagnostic>,
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<Diagnostic> = [];
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<InputFile> = 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<string>) {
const options: CommandLineArguments = yargs(argv)
.command('$0 <input>',
'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);
}