Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for --watch/-w #413

Merged
merged 2 commits into from
Apr 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Released: TBD

### Minor Changes

- [#404](https://github.com/peggyjs/peggy/issues/404) Add support for -w/--watch
to the command line interface. From @hildjj.

### Bug Fixes

3.0.2
Expand Down
92 changes: 82 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,54 @@ 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() => {
PeggyCLI.print(this.std.err, `"${that.inputFile}" changed...`);
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", er => {
reject(er);
});
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);
}
);
}
86 changes: 86 additions & 0 deletions bin/watcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"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);
}
};
const closed = () => this.emit("close");

this.watcher = fs.watch(dir);
this.watcher.on("error", er => {
this.watcher.off("close", closed);
this.watcher.once("close", () => this.emit("error", er));
this.watcher.close();
this.watcher = null;
});
this.watcher.on("close", closed);
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/benchmark-bundle.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/js/test-bundle.min.js

Large diffs are not rendered by default.

Loading