UNPKG

@dicy/cli

Version:

Command line interface to DiCy, a builder for LaTeX, knitr, literate Agda, literate Haskell and Pweave that automatically builds dependencies.

310 lines (309 loc) 14.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const chalk = require('chalk'); const url2path = require('file-uri-to-path'); const fileUrl = require('file-url'); const fs = require("fs-extra"); const yaml = require("js-yaml"); const _ = require("lodash"); const path = require("path"); const stringWidth = require('string-width'); const yargs = require('yargs/yargs'); const wrapAnsi = require('wrap-ansi'); const core_1 = require("@dicy/core"); const COMMANDS = ['build', 'clean', 'graph', 'log', 'scrub', 'test']; const ABBREVIATED_COMMANDS_PATTERN = new RegExp(`^[${COMMANDS.map(command => command.substr(0, 1)).join('')}]+$`); // Function to right pad a string based on the string width reported by then // string-width module. function padEnd(text, size) { return text + ' '.repeat(Math.max(size - stringWidth(text), 0)); } class Program { constructor(args = []) { // List commands associated with each alias. this.commandLists = {}; // The black box that makes it all happen. this.dicy = new core_1.DiCy(); // All messages associated with each input in case `--save-logs` is set. this.logs = new Map(); // Option definitions used to construct yargs options and help messages. this.optionDefinitions = []; // Actual option names used by DiCy indexed by command line form. For // instance, `{ 'foo2bar-quux': 'foo2barQuux' }` this.optionNames = {}; // Severity labels. Right padding will be added in the constructor. this.severityLabels = { trace: chalk.green('TRACE'), info: chalk.blue('INFO'), warning: chalk.yellow('WARNING'), error: chalk.red('ERROR') }; // Calculate the width of the terminal and the width of the severity column. const totalWidth = Math.min(Math.max(process.stdout.columns || 80, 80), 132); const severityWidth = Object.values(this.severityLabels).reduce((width, label) => Math.max(width, stringWidth(label)), 0) + 2; // Pad the severity labels. for (const label in this.severityLabels) { this.severityLabels[label] = padEnd(this.severityLabels[label], severityWidth); } // Create a spacer for line breaks in the text column this.textNewLine = '\n' + ' '.repeat(severityWidth); // Calculate the width of the text column this.textWidth = totalWidth - severityWidth; // Listen to the log event on DiCy. this.dicy.on('log', (file, messages) => { this.saveMessages(file, messages); this.printMessages(file, messages); }); // Common initialization for yargs. this.yargs = yargs(args); this.yargs .wrap(totalWidth) .usage('DiCy - A builder for LaTeX, knitr, literate Agda, literate Haskell and Pweave that automatically builds dependencies.') .demandCommand(1, 'You need to specify a command.') .recommendCommands() .help(); } /** * Destroy everything!! */ destroy() { return __awaiter(this, void 0, void 0, function* () { yield this.dicy.destroy(); }); } /** * Get the command line options associated with a specific OptionDefinition. * Multiple options will be returned for boolean options. * @param {OptionDefinition} definition The definition of the option. * @return {{ [name: string ]: any }} The command line options. */ getOptions(definition) { const options = {}; // Make a kebab but don't skewer the numbers. const name = _.kebabCase(definition.name).replace(/(?:^|-)([0-9]+)(?:$|-)/g, '$1'); const negatedName = `no-${name}`; const description = definition.description; // Only include the aliases that are a single letter. const alias = (definition.aliases || []).filter(alias => alias.length === 1); switch (definition.type) { case 'strings': options[name] = { type: 'array', alias, description }; break; case 'number': options[name] = { type: 'number', alias, description }; break; case 'boolean': if (definition.defaultValue) { // The default value is true so the negated option gets the aliases // and the description while the non-negated option is hidden. options[name] = { type: 'boolean', hidden: true }; options[negatedName] = { type: 'boolean', alias, description: description.replace('Enable', 'Disable') }; } else { // The default value is false so the non-negated option gets the // aliases and the description while the negated option is hidden. options[negatedName] = { type: 'boolean', hidden: true }; options[name] = { type: 'boolean', alias, description }; } break; case 'string': options[name] = { type: 'string', alias, description }; break; } // Make an index of the option names and add choices for enumerations. for (const name in options) { this.optionNames[name] = definition.name; if (definition.values) { options[name].choices = definition.values; } } return options; } /** * Get all command line options. * @return {{ [name: string ]: any }} The command line options. */ getAllOptions() { const options = { 'save-log': { boolean: true, description: 'Save the log as a YAML file <name>-log.yaml.' } }; // Skip environment variables Object.assign(options, ...this.optionDefinitions .filter(definition => !definition.name.startsWith('$')) .map(definition => this.getOptions(definition))); return options; } /** * Initialize the program. This loads the options definitions and creates the * command definition. * @return {Promise<void>} */ initialize() { return __awaiter(this, void 0, void 0, function* () { // Load the option definitions. this.optionDefinitions = yield core_1.getOptionDefinitions(); // Setup the default command with a function to coerce and validate the // command list. this.yargs.command({ command: `$0 <commands> <inputs...>`, describe: 'Run a series of commands on the supplied inputs.', builder: (yargs) => { return yargs .positional('commands', { type: 'string', describe: 'A command or a list commands to run. Possible values ' + 'include "build", "clean", "graph", "log", "scrub" or "test". Commands ' + 'may be abbreviated by using the first letter of command. A ' + 'sequence of commands may be composed by separating the commands ' + 'with commmands, i.e. "build,log,clean". Command abbreviations ' + 'may be combined without separating commands. For instance, "blc" ' + 'is equivalent to "build,log,clean".', coerce: (arg) => { // If the command string is clearly a concatenation of // abbreviated commands then split on character boundry, // otherwise split on commas. const abbreviatedCommands = arg.split(ABBREVIATED_COMMANDS_PATTERN.test(arg) ? '' : ','); // Lookup each command in the list of allowed commands. return abbreviatedCommands.map(abbreviatedCommand => { const command = COMMANDS.find(pc => pc.startsWith(abbreviatedCommand)); if (!command) throw new TypeError(`Unknown command: ${abbreviatedCommand}`); return command; }); } }) .positional('inputs', { type: 'string', describe: 'Input files to run commands on.' }) .options(this.getAllOptions()) .epilogue('All boolean options can be negated by adding or removing the `no-` prefix.'); } }); }); } /** * Saves the messages from a log event in case `--save-logs` is set. * @param {Uri} file Primary build file. * @param {Message[]} messages Array of new messages. */ saveMessages(file, messages) { const current = this.logs.get(file) || []; this.logs.set(file, current.concat(messages)); } /** * Create string representation of a reference and return an empty string if * the reference is missing. * @param {Reference | undefined} reference The reference. * @param {string} label A label to describe the reference. * @return {string} The string representing the reference. */ referenceToString(reference, label) { if (!reference) return ''; const start = reference.range && reference.range.start ? ` @ ${reference.range.start}` : ''; const end = reference.range && reference.range.end && reference.range.end !== reference.range.start ? `-${reference.range.end}` : ''; return chalk.dim(`\n[${label}] ${reference.file}${start}${end}`); } /** * Print a message from a log event. * @param {Uri} file Primary build file. * @param {Message} message New message. */ printMessage(file, message) { const origin = (message.name || message.category) ? `[${message.name}${message.category ? '/' : ''}${message.category || ''}] ` : ''; const source = this.referenceToString(message.source, 'Source'); const log = this.referenceToString(message.log, 'Log'); const text = wrapAnsi(origin + message.text + source + log, this.textWidth); console.log(this.severityLabels[message.severity] + text.replace(/\n/g, this.textNewLine)); } /** * Print the messages from a log event. * @param {Uri} file Primary build file. * @param {Message[]} messages Array of new messages. */ printMessages(file, messages) { for (const message of messages) { this.printMessage(file, message); } } /** * Processes a command with arguments supplied by the option parser. * @param {object} argv The arguments */ run(argv) { return __awaiter(this, void 0, void 0, function* () { const saveLog = !!argv['save-log']; const options = {}; const commands = ['load'].concat(argv.commands, ['save']); const files = (argv.inputs || []).map(fileUrl); this.initializeLogs(files); for (const name in argv) { const value = argv[name]; if (name in this.optionNames && value !== undefined && value !== false) { options[this.optionNames[name]] = name.startsWith('no-') ? !argv[name] : argv[name]; } } // Set all the instance options at once. yield Promise.all(files.map(file => this.dicy.setInstanceOptions(file, options))); // Start the builds concurrently. const success = (yield Promise.all(files.map(file => this.dicy.run(file, commands)))).every(x => x); if (saveLog) yield this.saveLogs(); yield this.dicy.destroy(); return success; }); } /** * Reset all saved logs. * @param {Uri[]} files The files that need logs. */ initializeLogs(files) { for (const file of files) { this.logs.set(file, []); } } /** * Save the logs by file. */ saveLogs() { return __awaiter(this, void 0, void 0, function* () { for (const [file, messages] of this.logs.entries()) { const { dir, name } = path.parse(url2path(file)); const logFilePath = path.join(dir, `${name}-log.yaml`); const contents = yaml.safeDump(messages, { skipInvalid: true }); yield fs.writeFile(logFilePath, contents); } }); } /** * Start the command line interface. * @return {Promise<boolean>} The build status. */ start() { return __awaiter(this, void 0, void 0, function* () { yield this.initialize(); return this.run(this.yargs.argv); }); } } exports.default = Program;