Skip to content

Commit e3d16b0

Browse files
authored
Merge pull request #62 from thefrontside/cl/pls-run
Add run command and documentation
2 parents 5891569 + 1953266 commit e3d16b0

13 files changed

Lines changed: 362 additions & 30 deletions

File tree

cli/deferred.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface Deferred<T = void> {
2+
promise: Promise<T>;
3+
resolve: (value: T) => void;
4+
reject: (error: Error) => void;
5+
}
6+
7+
export function defer<T>(): Deferred<T> {
8+
let resolve: Deferred<T>["resolve"] | undefined = void 0;
9+
let reject: Deferred<T>["reject"] | undefined = void 0;
10+
let promise = new Promise<T>((res, rej) => {
11+
resolve = res;
12+
reject = rej;
13+
});
14+
if (!resolve || !reject) {
15+
throw new Error("unable to create Deferred!");
16+
} else {
17+
return { resolve, reject, promise };
18+
}
19+
}

cli/main.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Operation, Task } from "../deps.ts";
2+
import { action, run, spawn, suspend } from "../deps.ts";
3+
import { defer } from "./deferred.ts";
4+
5+
export function main(op: (args: string[]) => Operation<void>): Task<void> {
6+
return run(function* Main() {
7+
let done = defer<number>();
8+
9+
yield* spawn(function* () {
10+
try {
11+
yield* op(Deno.args);
12+
done.resolve(0);
13+
} catch (error) {
14+
console.error(String(error));
15+
done.resolve(1);
16+
}
17+
});
18+
19+
let code = yield* action<number>(function* (resolve) {
20+
done.promise.then(resolve);
21+
let interrupt = () => resolve(1);
22+
try {
23+
Deno.addSignalListener("SIGINT", interrupt);
24+
yield* suspend();
25+
} finally {
26+
Deno.removeSignalListener("SIGINT", interrupt);
27+
}
28+
});
29+
30+
Deno.exit(code);
31+
});
32+
}

cli/pls-command.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Route } from "./router.ts";
2+
3+
import VERSION from "../version.json" assert { type: "json" };
4+
5+
export const PlsCommand: Route = {
6+
options: [
7+
{
8+
type: "boolean",
9+
name: "version",
10+
alias: "V",
11+
description: "Print version information",
12+
},
13+
],
14+
help: {
15+
HEAD: `pls ${VERSION}`,
16+
USAGE: "pls [OPTIONS] COMMAND",
17+
},
18+
*handle({ flags, children }) {
19+
if (flags.version) {
20+
console.log(VERSION);
21+
} else {
22+
yield* children;
23+
}
24+
},
25+
};

cli/pls.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { main } from "./main.ts";
2+
import { dispatch } from "./router.ts";
3+
import { PlsCommand } from "./pls-command.ts";
4+
import { RunCommand } from "./run-command.ts";
5+
6+
await main(function* (args) {
7+
yield* dispatch(["pls", ...args], {
8+
"pls": [PlsCommand, {
9+
"run :MODULE": RunCommand,
10+
}],
11+
});
12+
});

cli/router.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { Operation } from "../deps.ts";
2+
3+
import {
4+
parse as parseFlags,
5+
ParseOptions,
6+
} from "https://deno.land/std@0.159.0/flags/mod.ts";
7+
8+
export function* dispatch(
9+
args: string[],
10+
routes: Routes,
11+
matched: string[] = [],
12+
): Operation<void> {
13+
let shellwords = parseFlags(args)._;
14+
match:
15+
for (let [key, value] of Object.entries(routes)) {
16+
let pattern: PatternElement[] = key.split(/\s/).map((name) => {
17+
if (name.startsWith(":")) {
18+
return { type: "dynamic", required: true, name: name.slice(1) };
19+
} else {
20+
return { type: "static", value: name };
21+
}
22+
});
23+
let segments: Record<string, string> = {};
24+
let rest = args.slice();
25+
let copy = shellwords.slice();
26+
for (let element of pattern) {
27+
let top = copy.shift();
28+
29+
if (element.type === "dynamic") {
30+
if (top) {
31+
segments[element.name] = String(top);
32+
rest.splice(rest.indexOf(String(top), 1));
33+
matched = matched.concat(element.name);
34+
}
35+
} else if (element.value !== top) {
36+
continue match;
37+
} else {
38+
rest.splice(rest.indexOf(element.value), 1);
39+
matched = matched.concat(element.value);
40+
}
41+
}
42+
let [handler, subroutes] = Array.isArray(value) ? value : [value];
43+
44+
let helpful = helpify(handler);
45+
46+
let parseOptions = routeOptions(helpful);
47+
48+
let flags = parseFlags(rest, parseOptions);
49+
50+
let children = subroutes
51+
? dispatch(flags._.map(String), subroutes, matched)
52+
: { *[Symbol.iterator]() {} };
53+
54+
let props: RouteProps = {
55+
flags,
56+
route: helpful,
57+
children,
58+
segments,
59+
};
60+
61+
return yield* helpful.handle(props);
62+
}
63+
}
64+
65+
function routeOptions(route: Route): ParseOptions {
66+
return route.options.reduce((options, spec) => {
67+
options[spec.type].push(spec.name);
68+
if (spec.alias) {
69+
options.alias[spec.name] = spec.alias;
70+
}
71+
return options;
72+
}, {
73+
boolean: [] as string[],
74+
number: [] as string[],
75+
string: [] as string[],
76+
alias: {} as Record<string, string>,
77+
stopEarly: true,
78+
});
79+
}
80+
81+
function helpify(route: Route): Route {
82+
let options = route.options.slice();
83+
options.splice(0, 0, {
84+
type: "boolean",
85+
name: "help",
86+
alias: "h",
87+
description: "Print help information",
88+
});
89+
return {
90+
...route,
91+
options,
92+
*handle(props) {
93+
if (props.flags.help) {
94+
printHelp(props.route);
95+
} else {
96+
yield* route.handle(props);
97+
}
98+
},
99+
};
100+
}
101+
102+
export type PatternElement = {
103+
type: "dynamic";
104+
name: string;
105+
required: boolean;
106+
} | {
107+
type: "static";
108+
value: string;
109+
};
110+
111+
export interface RouteProps<TFlags = unknown> {
112+
flags: TFlags;
113+
route: Route;
114+
segments: Record<string, string>;
115+
children: Operation<void>;
116+
}
117+
118+
export interface Route {
119+
options: {
120+
type: "boolean" | "string" | "number";
121+
name: string;
122+
alias?: string;
123+
description: string;
124+
}[];
125+
help: {
126+
HEAD: string;
127+
USAGE: string;
128+
};
129+
handle(props: RouteProps): Operation<void>;
130+
}
131+
132+
export type Routes = Record<string, Route | [Route, Routes]>;
133+
134+
function printHelp(route: Route): void {
135+
let optionsTable = route.options.map((opt) => {
136+
let line = new Array(80).fill(" ", 0, 79);
137+
if (opt.alias) {
138+
line.splice(4, 3, ...`-${opt.alias},`);
139+
}
140+
line.splice(8, opt.name.length + 2, ...`--${opt.name}`);
141+
line.splice(20, opt.description.length, ...opt.description);
142+
return line.join("");
143+
}).join("\n");
144+
console.log(`${route.help.HEAD}\n`);
145+
console.log(`USAGE:\n ${route.help.USAGE}\n`);
146+
console.log(`OPTIONS:\n${optionsTable}`);
147+
}

cli/run-command.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Route } from "./router.ts";
2+
import { load, print } from "../mod.ts";
3+
import { LogContext, resolve, spawn } from "../deps.ts";
4+
5+
export const RunCommand: Route = {
6+
options: [],
7+
help: {
8+
HEAD: "Evaluate a PlatformScript program",
9+
USAGE: "pls run [OPTIONS] MODULE",
10+
},
11+
*handle({ segments, route }) {
12+
if (!segments.MODULE) {
13+
console.error(`USAGE: ${route.help.USAGE}
14+
missing required argument MODULE`);
15+
return;
16+
}
17+
18+
let address = segments.MODULE;
19+
20+
let location = isURL(address) ? address : `file://${resolve(address)}`;
21+
// setup logging context to output to stdout.
22+
yield* spawn(function* () {
23+
let logs = yield* LogContext;
24+
let i = yield* logs.output;
25+
for (let next = yield* i; !next.done; next = yield* i) {
26+
console.log(next.value.message);
27+
}
28+
});
29+
// load the module
30+
let mod = yield* load({ location });
31+
32+
// print its value
33+
let str = print(mod.value);
34+
console.log(str.value);
35+
},
36+
};
37+
38+
function isURL(value: string): boolean {
39+
try {
40+
new URL(value);
41+
return true;
42+
} catch (_) {
43+
return false;
44+
}
45+
}

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"tasks": {
3-
"test": "deno test --allow-read --allow-net",
3+
"pls": "deno run -A cli/pls.ts",
4+
"test": "deno test --allow-read --allow-net --allow-run",
45
"build:npm": "deno run -A tasks/build-npm.ts",
56
"changelog-entry": "deno run -A tasks/changelog-entry.ts",
67

pls.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

test/cli.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from "./suite.ts";
2+
import VERSION from "../version.json" assert { type: "json" };
3+
4+
describe("pls", () => {
5+
it("can be invoked for help", async () => {
6+
expect(await exec("-h")).toContain(VERSION);
7+
expect(await exec("--help")).toContain(VERSION);
8+
});
9+
10+
it("can be invoked for version", async () => {
11+
expect(await exec("-V")).toEqual(`${VERSION}\n`);
12+
expect(await exec("--version")).toEqual(`${VERSION}\n`);
13+
});
14+
15+
describe("run", () => {
16+
it("evaluates a module and prints it", async () => {
17+
expect(await exec("run test/modules/1.yaml")).toEqual("1\n");
18+
});
19+
});
20+
});
21+
22+
async function exec(str: string) {
23+
let p = Deno.run({
24+
stdout: "piped",
25+
stderr: "piped",
26+
cmd: ["deno", "run", "-A", "cli/pls.ts", ...str.split(/\s+/)],
27+
});
28+
try {
29+
let status = await p.status();
30+
let output = new TextDecoder().decode(await p.output());
31+
if (!status.success) {
32+
throw new Error(`${status}: ${output}`);
33+
} else {
34+
return output;
35+
}
36+
} finally {
37+
p.close();
38+
p.stderr.close();
39+
}
40+
}

www/components/main-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function createMainPage<TProps extends MainPageProps = MainPageProps>(
2323
</Head>
2424
<div class="h-screen flex flex-col">
2525
<Header className="flex justify-between" active={props.data.active} />
26-
<main style={{flexGrow: 1}}>
26+
<main style={{ flexGrow: 1 }}>
2727
<Component {...props} />
2828
</main>
2929
<Footer className="border(t-2 gray-200) bg-gray-100 h-32 flex flex-col gap-4 justify-center" />

0 commit comments

Comments
 (0)