Skip to content

Commit

Permalink
Add support for --watch to CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
hildjj committed Apr 9, 2023
1 parent aed4b38 commit 2f6ae4f
Show file tree
Hide file tree
Showing 9 changed files with 538 additions and 234 deletions.
90 changes: 80 additions & 10 deletions bin/peggy-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ function readFile(name) {
* @property {stream.Writable} [err] StdErr.
*/

/**
* @typedef {object} ErrorOptions
* @property {string} [code="peggy.invalidArgument"] Code for exception if
* throwing.
* @property {number} [exitCode=1] Exit code if exiting.
* @property {peggy.SourceText[]} [sources=[]] Source text for formatting compile errors.
* @property {Error} [error] Error to extract message from.
* @property {string} [message] Error message, only used internally.
*/

// Command line processing
class PeggyCLI extends Command {
/**
Expand Down Expand Up @@ -98,6 +108,10 @@ class PeggyCLI extends Command {
this.testText = null;
/** @type {string?} */
this.outputJS = null;
/** @type {string?} */
this.lastError = null;
/** @type {import('./watcher.js')?} */
this.watcher = null;

this
.version(peggy.VERSION, "-v, --version")
Expand Down Expand Up @@ -170,7 +184,7 @@ class PeggyCLI extends Command {
.choices(MODULE_FORMATS)
.default("commonjs")
)
.option("-o, --output <file>", "Output file for generated parser. Use '-' for stdout (the default, unless a test is specified, in which case no parser is output without this option)")
.option("-o, --output <file>", "Output file for generated parser. Use '-' for stdout (the default is a file next to the input file with the extension change to '.js', unless a test is specified, in which case no parser is output without this option)")
.option(
"--plugin <module>",
"Comma-separated list of plugins. (can be specified multiple times)",
Expand All @@ -193,6 +207,7 @@ class PeggyCLI extends Command {
"Test the parser with the contents of the given file, outputting the result of running the parser instead of the parser itself. If the input to be tested is not parsed, the CLI will exit with code 2. A filename of '-' will read from stdin."
).conflicts("test"))
.option("--trace", "Enable tracing in generated parser", false)
.option("-w,--watch", "Watch the input file for changes, generating the output once at the start, and again whenever the file changes.")
.addOption(
// Not interesting yet. If it becomes so, unhide the help.
new Option("--verbose", "Enable verbose logging")
Expand Down Expand Up @@ -274,9 +289,14 @@ class PeggyCLI extends Command {
this.outputFile = this.progOptions.output;
this.outputJS = this.progOptions.output;

if ((this.inputFile === "-") && this.argv.watch) {
this.argv.watch = false; // Make error throw.
this.error("Can't watch stdin");
}

if (!this.outputFile) {
if (this.inputFile !== "-") {
this.outputJS = this.inputFile.substr(
this.outputJS = this.inputFile.slice(
0,
this.inputFile.length - path.extname(this.inputFile).length
) + ".js";
Expand Down Expand Up @@ -341,12 +361,7 @@ class PeggyCLI extends Command {
* message provided.
*
* @param {string} message The message to print.
* @param {object} [opts] Options
* @param {string} [opts.code="peggy.invalidArgument"] Code for exception if
* throwing.
* @param {number} [opts.exitCode=1] Exit code if exiting.
* @param {peggy.SourceText[]} [opts.sources=[]] Source text for formatting compile errors.
* @param {Error} [opts.error] Error to extract message from.
* @param {ErrorOptions} [opts] Options
*/
error(message, opts = {}) {
opts = {
Expand All @@ -370,7 +385,11 @@ class PeggyCLI extends Command {
message = `Error ${message}`;
}

super.error(message, opts);
if (this.argv.watch) {
this.lastError = message;
} else {
super.error(message, opts);
}
}

static print(stream, ...args) {
Expand Down Expand Up @@ -604,7 +623,12 @@ class PeggyCLI extends Command {
}
}

async main() {
/**
* Process the command line once.
*
* @returns {Promise<number>}
*/
async run() {
let inputStream = undefined;

if (this.inputFile === "-") {
Expand Down Expand Up @@ -666,6 +690,52 @@ class PeggyCLI extends Command {
return 0;
}

/**
* Stops watching input file.
*/
async stopWatching() {
if (!this.watcher) {
throw new Error("Not watching");
}
await this.watcher.close();
this.watcher = null;
}

/**
* Entry point. If in watch mode, does `run` in a loop, catching errors,
* otherwise does `run` once.
*
* @returns {Promise<number>}
*/
main() {
if (this.argv.watch) {
const Watcher = require("./watcher.js"); // Lazy: usually not needed.
const hasTest = this.progOptions.test || this.progOptions.testFile;

this.watcher = new Watcher(this.inputFile);

const that = this;
this.watcher.on("change", async event => {
PeggyCLI.print(this.std.err, `"${that.inputFile}" ${event}...`);
this.lastError = null;
await that.run();

if (that.lastError) {
PeggyCLI.print(this.std.err, that.lastError);
} else if (!hasTest) {
PeggyCLI.print(this.std.err, `Wrote: "${that.outputFile}"`);
}
});

return new Promise((resolve, reject) => {
this.watcher.on("error", reject);
this.watcher.on("close", () => resolve(0));
});
} else {
return this.run();
}
}

// For some reason, after running through rollup, typescript can't see
// methods from the base class.

Expand Down
5 changes: 4 additions & 1 deletion bin/peggy.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ if (require.main === module) {
const cli = new PeggyCLI().parse();
cli.main().then(
code => process.exit(code),
er => console.error("Uncaught Error\n", er)
er => {
console.error("Uncaught Error\n", er);
process.exit(1);
}
);
}
83 changes: 83 additions & 0 deletions bin/watcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use strict";

const fs = require("fs");
const path = require("path");
const { EventEmitter } = require("events");

// This may have to be tweaked based on experience.
const DEBOUNCE_MS = 100;

/**
* Relatively feature-free file watcher that deals with some of the
* idiosyncrasies of fs.watch. On some OS's, change notifications are doubled
* up. Watch the owning directory instead of the file, so that when the file
* doesn't exist then gets created we get a change notification instead of an
* error. When the file is moved in or out of the directory, don't track the
* inode of the original file. No notification is given on file deletion,
* just when the file is ready to be read.
*/
class Watcher extends EventEmitter {
/**
* Creates an instance of Watcher.
*
* @param {string} filename The file to watch. Should be a plain file,
* not a directory, pipe, etc.
*/
constructor(filename) {
super();

const rfile = path.resolve(filename);
const { dir, base } = path.parse(rfile);
let timeout = null;

// eslint-disable-next-line func-style -- Needs this.
const changed = (typ, fn) => {
if (fn === base) {
if (!timeout) {
fs.stat(rfile, (er, stats) => {
if (!er && stats.isFile()) {
this.emit("change", stats);
}
});
} else {
clearTimeout(timeout);
}

// De-bounce
timeout = setTimeout(() => {
timeout = null;
}, Watcher.interval);
}
};

this.watcher = fs.watch(dir);
this.watcher.on("error", er => {
this.watcher.once("close", () => this.emit("error", er));
this.watcher.close();
});
this.watcher.on("close", er => this.emit("close", er));
this.watcher.on("change", changed);

// Fire initial time if file exists.
setImmediate(() => changed("rename", base));
}

/**
* Close the watcher. Safe to call multiple times.
*
* @returns {Promise<void>} Always resolves.
*/
close() {
return new Promise(resolve => {
if (this.watcher) {
this.watcher.once("close", resolve);
this.watcher.close();
} else {
resolve();
}
this.watcher = null;
});
}
}
Watcher.interval = DEBOUNCE_MS;
module.exports = Watcher;
2 changes: 1 addition & 1 deletion docs/js/test-bundle.min.js

Large diffs are not rendered by default.

Loading

0 comments on commit 2f6ae4f

Please sign in to comment.