regar/regar.ts

186 lines
5.3 KiB
TypeScript
Executable file

#!/usr/bin/env -S deno run -A
import { exists } from "https://deno.land/std@0.224.0/fs/exists.ts";
import { Command } from "https://deno.land/x/cliffy@v1.0.0-rc.4/command/mod.ts";
import { Channel, Constructible, InstanceOf } from "https://git.barnulf.net/mb/barnulf_ts/raw/branch/master/mod.ts";
async function main() {
const { do_clear, command, extensions, files, strategy } = await parse_args();
const channel = new Channel<Event>();
files.map((path) => new Watcher(path, extensions).spin(channel));
new Runner(command, strategy, do_clear).spin(channel);
}
async function parse_args() {
const { args, options } = await new Command()
.name("regar")
.description("File watcher development utility.")
.arguments("<command> [...files]")
.option("-r, --hard", "Ends the child process by killing it.")
.option("-s, --soft", "Ends the child process by sending an interuption and waiting for it to shut down.")
.option("-w, --wait", "Wait for the child process to complete without terminating it. (default)")
.option("-e, --extensions <extentions>", "Comma separated whitelist of extensions to whatch.")
.option("-c, --clear", "Clear the terminal on restart").parse();
const [command, ...files] = args;
let strategy: ShutdownStrategy = new WaitStrategy();
if (options.soft) strategy = new SoftStrategy();
if (options.hard) strategy = new HardStrategy();
if (options.wait) strategy = new WaitStrategy();
const extensions = options.extensions?.split(",");
const do_clear = options.clear ?? false;
return { command, files, strategy, extensions, do_clear };
}
class Watcher {
public constructor(
private path: string,
private extension_whitelist: string[] | undefined,
) {}
public async spin(channel: Channel<Event>) {
const relevant_events: Deno.FsEvent["kind"][] = ["create", "modify", "remove", "rename"];
for await (const event of Deno.watchFs(this.path, { recursive: true })) {
if (!relevant_events.includes(event.kind)) continue;
if (!this.matches_whitelist(event.paths[0])) continue;
for (const path of event.paths) channel.send(new Event(path, Date.now()));
}
}
private matches_whitelist(path: string) {
if (this.extension_whitelist === undefined) return true;
for (const ext of this.extension_whitelist) if (path.endsWith(ext)) return true;
return false;
}
}
class Runner {
public constructor(
private command: string,
private strategy: ShutdownStrategy,
private do_clear: boolean,
) {}
public async spin(channel: Channel<Event>) {
this.could_clear();
let process = this.launch();
while (true) {
const event = await channel.receive();
if (this.debouncing(event)) continue;
const shotdown = await this.strategy.shutdown(process);
if (!shotdown) continue;
this.could_clear();
log("changed", event.path);
process = this.launch();
}
}
private launch() {
const args = ["-c", this.command];
log("running", this.command);
const process = new Deno.Command("sh", { args }).spawn();
process.status.then(this.on_end());
return process;
}
private could_clear() {
const clear_term_sequence = "\x1bc";
if (this.do_clear) console.log(clear_term_sequence);
}
private last_event_date_ms = 0;
private debouncing(event: Event) {
const delay_ms = event.date_ms - this.last_event_date_ms;
const threshold_ms = 100;
if (delay_ms < threshold_ms) return true;
this.last_event_date_ms = event.date_ms;
return false;
}
private on_end() {
const start_ms = Date.now();
return () => {
const end_ms = Date.now();
const time_ms = end_ms - start_ms;
const secs = time_ms / 1000;
log("terminated", `${secs} s`);
};
}
}
interface ShutdownStrategy {
// Returns wether the child has been shutdown.
shutdown(process_: Deno.ChildProcess): Promise<boolean>;
}
class WaitStrategy implements ShutdownStrategy {
async shutdown(process_: Deno.ChildProcess) {
if (await process_exists(process_.pid)) return false;
else return true;
}
}
class SoftStrategy implements ShutdownStrategy {
async shutdown(process_: Deno.ChildProcess) {
try_signal(process_.pid, "SIGINT");
await process_.output();
return true;
}
}
class HardStrategy implements ShutdownStrategy {
async shutdown(process_: Deno.ChildProcess) {
try_signal(process_.pid, "SIGKILL");
return await true;
}
}
class Event {
constructor(
public path: string,
public date_ms: number,
) {}
}
async function process_exists(pid: number) {
return await exists(`/proc/${pid}`);
}
function try_signal(pid: number, sig: Deno.Signal) {
catch_(
() => Deno.kill(pid, sig),
[Deno.errors.NotFound, "ignore"],
);
}
// deno-lint-ignore no-explicit-any
type Catching<C extends Constructible<C, any>, R = void> = [
exception: C,
strategy: ((exc: InstanceOf<C>) => R) | "ignore",
];
// deno-lint-ignore no-explicit-any
function catch_<T, R, Cs extends Catching<any, R>[]>(op: () => T, ...catchings: Cs) {
try {
return op();
} catch (exception) {
for (const [kind, strategy] of catchings) {
if (!(exception instanceof kind)) continue;
if (strategy === "ignore") return;
return strategy(exception);
}
}
}
function log(verb: string, value: unknown) {
const yellow = "\x1b[0;33m";
const bold = "\x1b[1m";
const reset = "\x1b[0m";
console.log(
`` +
`${yellow}${bold}${verb.padStart(12)}${reset}` +
` ` +
`${yellow}${value}${reset}`,
);
}
if (import.meta.main) await main();