recipe-tmlanguage 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/{recipe-tmlang.mjs → cli.mjs} +121 -81
- package/bin/cli.ts +27 -0
- package/bin/commands/generate.ts +36 -0
- package/bin/commands/verify.ts +48 -0
- package/bin/lib/utils.ts +29 -0
- package/package.json +13 -8
- package/src/deps/oniguruma.ts +35 -0
- package/src/deps/textmate.ts +8 -0
- package/src/grammar.ts +3 -1
- package/src/verifier.ts +7 -16
- package/bin/recipe-tmlang.ts +0 -106
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import {
|
|
3
|
+
import { cli, command, flag } from "@kjanat/dreamcli";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { cwd, exit } from "node:process";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { cli, command, flag } from "dreamcli";
|
|
8
6
|
import { COUNTERS, NUMBER_WORDS, PERIODS, PERIOD_PLURALS } from "tree-sitter-recipe/grammar/dutch";
|
|
9
7
|
import { COMPOUNDING, COMPOUNDING_MULTIWORD, CONDITIONAL, CONDITIONAL_MULTIWORD, DISPENSING, DISPENSING_MULTIWORD, FORMS, FORMS_MULTIWORD, FREQUENCY, ROUTE, ROUTE_MULTIWORD, TIMING, TIMING_MULTIWORD, WARNING } from "tree-sitter-recipe/grammar/latin";
|
|
10
8
|
import { UNITS } from "tree-sitter-recipe/grammar/units";
|
|
9
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { cwd, exit } from "node:process";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
//#region bin/lib/utils.ts
|
|
13
|
+
const require$1 = createRequire(import.meta.url);
|
|
14
|
+
const toPath = (resolved) => resolved.startsWith("file:") ? fileURLToPath(resolved) : resolved;
|
|
15
|
+
function packageDir(specifier) {
|
|
16
|
+
return resolve(dirname(toPath(require$1.resolve(specifier))));
|
|
17
|
+
}
|
|
18
|
+
function resolveImportMeta(specifier) {
|
|
19
|
+
return toPath(import.meta.resolve(specifier));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Default path of the generated grammar: `recipe.tmLanguage.json` at the
|
|
23
|
+
* package root. Anchored on `#pkg` (package.json / deno.json — always present)
|
|
24
|
+
* rather than `#tmLang`, because the grammar is generated + gitignored and may
|
|
25
|
+
* not exist yet on a fresh checkout, and `import.meta.resolve` throws on a
|
|
26
|
+
* missing target. Existence is enforced later, where the file is actually read.
|
|
27
|
+
*/
|
|
28
|
+
function defaultGrammarPath() {
|
|
29
|
+
return resolve(dirname(resolveImportMeta("#pkg")), "recipe.tmLanguage.json");
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
11
32
|
//#region src/grammar.ts
|
|
12
33
|
/**
|
|
13
34
|
* @file Pure grammar builder — imports the tree-sitter-recipe vocabulary and
|
|
@@ -16,6 +37,8 @@ import { UNITS } from "tree-sitter-recipe/grammar/units";
|
|
|
16
37
|
*
|
|
17
38
|
* Scopes are standard TextMate names with a `.recipe` suffix so themes paint
|
|
18
39
|
* recipe blocks without a custom theme shipment.
|
|
40
|
+
*
|
|
41
|
+
* @module recipe-tmlanguage/src/grammar
|
|
19
42
|
*/
|
|
20
43
|
const SCOPE = {
|
|
21
44
|
rxMarker: "keyword.control.directive.rx.recipe",
|
|
@@ -45,7 +68,7 @@ const SCOPE = {
|
|
|
45
68
|
const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
|
|
46
69
|
const escapeRegex = (s) => s.replace(REGEX_METACHARS, "\\$&");
|
|
47
70
|
const alt = (items) => [...new Set(items)].sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
48
|
-
const altMultiword = (items) => [...new Set(items)].sort((a, b) => b.length - a.length).map((s) => s
|
|
71
|
+
const altMultiword = (items) => [...new Set(items)].sort((a, b) => b.length - a.length).map((s) => escapeRegex(s).replace(/\s+/g, "\\s+")).join("|");
|
|
49
72
|
const wb = (pattern) => `(?<![\\w.])(?:${pattern})(?![\\w.])`;
|
|
50
73
|
function buildGrammar() {
|
|
51
74
|
const doseMatch = {
|
|
@@ -267,6 +290,61 @@ function serializeGrammar(g, indent) {
|
|
|
267
290
|
return `${JSON.stringify(g, null, indent === "tab" ? " " : indent)}\n`;
|
|
268
291
|
}
|
|
269
292
|
//#endregion
|
|
293
|
+
//#region bin/commands/generate.ts
|
|
294
|
+
const indentOf = (raw) => raw === "tab" ? "tab" : Number(raw);
|
|
295
|
+
const DEFAULT_OUT$1 = defaultGrammarPath();
|
|
296
|
+
const generateCmd = command("generate").description("Build the TextMate grammar from the tree-sitter-recipe vocabulary").flag("out", flag.string().alias("o").default(DEFAULT_OUT$1).describe("Output JSON path")).flag("indent", flag.enum([
|
|
297
|
+
"tab",
|
|
298
|
+
"2",
|
|
299
|
+
"4"
|
|
300
|
+
]).default("tab").describe("JSON indent")).flag("quiet", flag.boolean().alias("q").default(false).describe("Suppress stats on success")).action(({ flags, out }) => {
|
|
301
|
+
const { grammar, stats } = buildGrammar();
|
|
302
|
+
const serialized = serializeGrammar(grammar, indentOf(flags.indent));
|
|
303
|
+
const outAbs = resolve(cwd(), flags.out);
|
|
304
|
+
mkdirSync(dirname(outAbs), { recursive: true });
|
|
305
|
+
writeFileSync(outAbs, serialized);
|
|
306
|
+
const { json, jsonMode, log } = out;
|
|
307
|
+
if (jsonMode) {
|
|
308
|
+
json({
|
|
309
|
+
ok: true,
|
|
310
|
+
outPath: outAbs,
|
|
311
|
+
bytes: serialized.length,
|
|
312
|
+
stats
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (flags.quiet) return;
|
|
317
|
+
log(`wrote ${outAbs}`);
|
|
318
|
+
log(` ${stats.topLevelPatterns} top-level patterns · ${serialized.length} bytes`);
|
|
319
|
+
const v = stats.vocab;
|
|
320
|
+
log(` vocab: ${v.frequency} frequency · ${v.timing.single}+${v.timing.multi} timing · ${v.route.single}+${v.route.multi} route · ${v.dispensing.single}+${v.dispensing.multi} dispensing · ${v.forms.single}+${v.forms.multi} forms · ${v.compounding.single}+${v.compounding.multi} compounding · ${v.conditional.single}+${v.conditional.multi} conditional · ${v.warning} warning · ${v.units} units`);
|
|
321
|
+
});
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/deps/oniguruma.ts
|
|
324
|
+
const require = createRequire(import.meta.url);
|
|
325
|
+
const oniguruma = require("vscode-oniguruma");
|
|
326
|
+
let loadPromise;
|
|
327
|
+
function loadOniguruma() {
|
|
328
|
+
loadPromise ??= (async () => {
|
|
329
|
+
const wasm = await readFile(require.resolve("vscode-oniguruma/release/onig.wasm"));
|
|
330
|
+
await oniguruma.loadWASM(wasm);
|
|
331
|
+
})();
|
|
332
|
+
return loadPromise;
|
|
333
|
+
}
|
|
334
|
+
async function createOnigLib() {
|
|
335
|
+
await loadOniguruma();
|
|
336
|
+
return {
|
|
337
|
+
createOnigScanner(patterns) {
|
|
338
|
+
return oniguruma.createOnigScanner(patterns);
|
|
339
|
+
},
|
|
340
|
+
createOnigString(string) {
|
|
341
|
+
return oniguruma.createOnigString(string);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const { loadWASM, createOnigScanner, createOnigString, OnigScanner, OnigString } = oniguruma;
|
|
346
|
+
const { parseRawGrammar, Registry, INITIAL } = createRequire(import.meta.url)("vscode-textmate");
|
|
347
|
+
//#endregion
|
|
270
348
|
//#region src/verifier.ts
|
|
271
349
|
/**
|
|
272
350
|
* @file Pure verifier — tokenizes tree-sitter-recipe's own highlight fixtures
|
|
@@ -275,10 +353,9 @@ function serializeGrammar(g, indent) {
|
|
|
275
353
|
*
|
|
276
354
|
* No CLI concerns here; the caller supplies paths and decides how to present
|
|
277
355
|
* the result (text table / JSON / exit code).
|
|
356
|
+
*
|
|
357
|
+
* @module recipe-tmlanguage/src/verifier
|
|
278
358
|
*/
|
|
279
|
-
const require = createRequire(import.meta.url);
|
|
280
|
-
const oniguruma = require("vscode-oniguruma");
|
|
281
|
-
const { parseRawGrammar, Registry } = require("vscode-textmate");
|
|
282
359
|
const CAPTURE_EXPECTS = {
|
|
283
360
|
"keyword.directive": "keyword.control.directive",
|
|
284
361
|
"keyword.repeat": "keyword.other.frequency",
|
|
@@ -331,16 +408,11 @@ function parseFixture(content, name) {
|
|
|
331
408
|
};
|
|
332
409
|
}
|
|
333
410
|
async function verify(opts) {
|
|
334
|
-
const
|
|
335
|
-
await oniguruma.loadWASM(wasmBin.buffer);
|
|
336
|
-
const onigLib = Promise.resolve({
|
|
337
|
-
createOnigScanner: (patterns) => new oniguruma.OnigScanner(patterns),
|
|
338
|
-
createOnigString: (s) => new oniguruma.OnigString(s)
|
|
339
|
-
});
|
|
411
|
+
const onigLib = createOnigLib();
|
|
340
412
|
const rawGrammar = parseRawGrammar(readFileSync(opts.grammarPath, "utf-8"), opts.grammarPath);
|
|
341
413
|
const grammar = await new Registry({
|
|
342
414
|
onigLib,
|
|
343
|
-
loadGrammar:
|
|
415
|
+
loadGrammar: () => Promise.resolve(null)
|
|
344
416
|
}).addGrammar(rawGrammar);
|
|
345
417
|
const result = {
|
|
346
418
|
pass: 0,
|
|
@@ -380,87 +452,55 @@ async function verify(opts) {
|
|
|
380
452
|
return result;
|
|
381
453
|
}
|
|
382
454
|
//#endregion
|
|
383
|
-
//#region
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"type": "git",
|
|
388
|
-
"url": "git+https://github.com/kjanat/recipe-tmlanguage.git"
|
|
389
|
-
};
|
|
390
|
-
//#endregion
|
|
391
|
-
//#region bin/recipe-tmlang.ts
|
|
392
|
-
/**
|
|
393
|
-
* recipe-tmlang — TextMate grammar generator & verifier for recipe-tmlanguage.
|
|
394
|
-
*
|
|
395
|
-
* Subcommands
|
|
396
|
-
* - generate: Build dist/recipe.tmLanguage.json from the tree-sitter-recipe vocab.
|
|
397
|
-
* - verify: Tokenize tree-sitter-recipe's highlight fixtures and assert scopes.
|
|
398
|
-
*
|
|
399
|
-
* Zero manual argparse — argument parsing, help, and completions all come from
|
|
400
|
-
* {@link https://github.com/kjanat/dreamcli | DreamCLI}. `--json` is a DreamCLI built-in;
|
|
401
|
-
* we branch on {@linkcode Out.jsonMode}.
|
|
402
|
-
*/
|
|
403
|
-
const DEFAULT_OUT = `${resolve(import.meta.dirname, "..")}/recipe.tmLanguage.json`;
|
|
404
|
-
const DEFAULT_FIXTURES_DIR = resolve(resolve(dirname(fileURLToPath(import.meta.resolve("tree-sitter-recipe/package.json")))), "test/highlight");
|
|
405
|
-
const DEFAULT_ONIG_WASM = fileURLToPath(import.meta.resolve("vscode-oniguruma/release/onig.wasm"));
|
|
406
|
-
const indentOf = (raw) => raw === "tab" ? "tab" : Number(raw);
|
|
407
|
-
const generate = command("generate").description("Build the TextMate grammar from the tree-sitter-recipe vocabulary").flag("out", flag.string().alias("o").default(DEFAULT_OUT).describe("Output JSON path")).flag("indent", flag.enum([
|
|
408
|
-
"tab",
|
|
409
|
-
"2",
|
|
410
|
-
"4"
|
|
411
|
-
]).default("tab").describe("JSON indent")).flag("quiet", flag.boolean().alias("q").default(false).describe("Suppress stats on success")).action(({ flags, out }) => {
|
|
412
|
-
const { grammar, stats } = buildGrammar();
|
|
413
|
-
const serialized = serializeGrammar(grammar, indentOf(flags.indent));
|
|
414
|
-
const outAbs = resolve(cwd(), flags.out);
|
|
415
|
-
mkdirSync(dirname(outAbs), { recursive: true });
|
|
416
|
-
writeFileSync(outAbs, serialized);
|
|
417
|
-
if (out.jsonMode) {
|
|
418
|
-
out.json({
|
|
419
|
-
ok: true,
|
|
420
|
-
outPath: outAbs,
|
|
421
|
-
bytes: serialized.length,
|
|
422
|
-
stats
|
|
423
|
-
});
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (flags.quiet) return;
|
|
427
|
-
out.log(`wrote ${outAbs}`);
|
|
428
|
-
out.log(` ${stats.topLevelPatterns} top-level patterns · ${serialized.length} bytes`);
|
|
429
|
-
const v = stats.vocab;
|
|
430
|
-
out.log(` vocab: ${v.frequency} frequency · ${v.timing.single}+${v.timing.multi} timing · ${v.route.single}+${v.route.multi} route · ${v.dispensing.single}+${v.dispensing.multi} dispensing · ${v.forms.single}+${v.forms.multi} forms · ${v.compounding.single}+${v.compounding.multi} compounding · ${v.conditional.single}+${v.conditional.multi} conditional · ${v.warning} warning · ${v.units} units`);
|
|
431
|
-
});
|
|
432
|
-
const verifyCmd = command("verify").description("Tokenize tree-sitter-recipe highlight fixtures and assert scope matches").flag("grammar", flag.string().alias("g").default(DEFAULT_OUT).describe("Path to .tmLanguage.json")).flag("fixtures", flag.string().alias("f").default(DEFAULT_FIXTURES_DIR).describe("Directory of .recipe fixtures")).flag("onig-wasm", flag.string().default(DEFAULT_ONIG_WASM).describe("Path to oniguruma WASM")).flag("max-failures", flag.number().default(40).describe("Max failures to print (0 = all)")).action(async ({ flags, out }) => {
|
|
455
|
+
//#region bin/commands/verify.ts
|
|
456
|
+
const DEFAULT_FIXTURES_DIR = resolve(packageDir("tree-sitter-recipe/package.json"), "test/highlight");
|
|
457
|
+
const DEFAULT_OUT = defaultGrammarPath();
|
|
458
|
+
const verifyCmd = command("verify").description("Tokenize tree-sitter-recipe highlight fixtures and assert scope matches").flag("grammar", flag.string().alias("g").default(DEFAULT_OUT).describe("Path to .tmLanguage.json")).flag("fixtures", flag.string().alias("f").default(DEFAULT_FIXTURES_DIR).describe("Directory of .recipe fixtures")).flag("max-failures", flag.number().default(40).describe("Max failures to print (0 = all)")).action(async ({ flags, out }) => {
|
|
433
459
|
const result = await verify({
|
|
434
460
|
grammarPath: resolve(cwd(), flags.grammar),
|
|
435
|
-
fixturesDir: resolve(cwd(), flags.fixtures)
|
|
436
|
-
onigWasmPath: resolve(cwd(), flags["onig-wasm"])
|
|
461
|
+
fixturesDir: resolve(cwd(), flags.fixtures)
|
|
437
462
|
});
|
|
438
463
|
const failuresLen = result.failures.length;
|
|
439
|
-
|
|
440
|
-
|
|
464
|
+
const { json, jsonMode, setExitCode, log } = out;
|
|
465
|
+
if (jsonMode) {
|
|
466
|
+
json(result);
|
|
441
467
|
if (failuresLen > 0) {
|
|
442
|
-
|
|
468
|
+
setExitCode(1);
|
|
443
469
|
exit();
|
|
444
470
|
}
|
|
445
471
|
return;
|
|
446
472
|
}
|
|
447
|
-
|
|
473
|
+
log(`${result.pass} / ${result.total} assertions pass`);
|
|
448
474
|
if (failuresLen === 0) return;
|
|
449
|
-
|
|
450
|
-
|
|
475
|
+
log("");
|
|
476
|
+
log("── failures ──");
|
|
451
477
|
const limit = flags["max-failures"] === 0 ? failuresLen : flags["max-failures"];
|
|
452
478
|
for (const f of result.failures.slice(0, limit)) {
|
|
453
479
|
const gotStr = f.got ? f.got.filter((s) => s !== "source.recipe").join(" · ") || "(root only)" : "(no token)";
|
|
454
|
-
|
|
480
|
+
log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
|
|
455
481
|
}
|
|
456
|
-
if (failuresLen > limit)
|
|
457
|
-
|
|
482
|
+
if (failuresLen > limit) log(` … +${failuresLen - limit} more`);
|
|
483
|
+
setExitCode(1);
|
|
458
484
|
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
485
|
+
//#endregion
|
|
486
|
+
//#region bin/cli.ts
|
|
487
|
+
/**
|
|
488
|
+
* recipe-tmlang — TextMate grammar generator & verifier for recipe-tmlanguage.
|
|
489
|
+
*
|
|
490
|
+
* Subcommands
|
|
491
|
+
* - generate: Build dist/recipe.tmLanguage.json from the tree-sitter-recipe vocab.
|
|
492
|
+
* - verify: Tokenize tree-sitter-recipe's highlight fixtures and assert scopes.
|
|
493
|
+
*
|
|
494
|
+
* Zero manual argparse — argument parsing, help, and completions all come from
|
|
495
|
+
* {@link https://github.com/kjanat/dreamcli | DreamCLI}. `--json` is a DreamCLI built-in;
|
|
496
|
+
* we branch on {@linkcode out.jsonMode | https://dreamcli.kjanat.com/reference/symbols/main/Out#jsonmode}.
|
|
497
|
+
*
|
|
498
|
+
* @module recipe-tmlanguage/bin
|
|
499
|
+
*/
|
|
500
|
+
const app = cli("recipe-tmlanguage").manifest({
|
|
501
|
+
from: import.meta.url,
|
|
502
|
+
files: ["package.json", "deno.json"]
|
|
503
|
+
}).links().description("TextMate grammar generator & verifier for the recipe DSL").command(generateCmd).command(verifyCmd).completions();
|
|
464
504
|
if (import.meta.main) app.run();
|
|
465
505
|
//#endregion
|
|
466
|
-
export {
|
|
506
|
+
export {};
|
package/bin/cli.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* recipe-tmlang — TextMate grammar generator & verifier for recipe-tmlanguage.
|
|
4
|
+
*
|
|
5
|
+
* Subcommands
|
|
6
|
+
* - generate: Build dist/recipe.tmLanguage.json from the tree-sitter-recipe vocab.
|
|
7
|
+
* - verify: Tokenize tree-sitter-recipe's highlight fixtures and assert scopes.
|
|
8
|
+
*
|
|
9
|
+
* Zero manual argparse — argument parsing, help, and completions all come from
|
|
10
|
+
* {@link https://github.com/kjanat/dreamcli | DreamCLI}. `--json` is a DreamCLI built-in;
|
|
11
|
+
* we branch on {@linkcode out.jsonMode | https://dreamcli.kjanat.com/reference/symbols/main/Out#jsonmode}.
|
|
12
|
+
*
|
|
13
|
+
* @module recipe-tmlanguage/bin
|
|
14
|
+
*/
|
|
15
|
+
import { cli } from "@kjanat/dreamcli";
|
|
16
|
+
import { generateCmd } from "./commands/generate.ts";
|
|
17
|
+
import { verifyCmd } from "./commands/verify.ts";
|
|
18
|
+
|
|
19
|
+
const app = cli("recipe-tmlanguage")
|
|
20
|
+
.manifest({ from: import.meta.url, files: ["package.json", "deno.json"] })
|
|
21
|
+
.links()
|
|
22
|
+
.description("TextMate grammar generator & verifier for the recipe DSL")
|
|
23
|
+
.command(generateCmd)
|
|
24
|
+
.command(verifyCmd)
|
|
25
|
+
.completions();
|
|
26
|
+
|
|
27
|
+
if (import.meta.main) app.run();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defaultGrammarPath } from "#bin/lib/utils.ts";
|
|
2
|
+
import { buildGrammar, serializeGrammar } from "#src/grammar.ts";
|
|
3
|
+
import { command, flag } from "@kjanat/dreamcli";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { cwd } from "node:process";
|
|
7
|
+
|
|
8
|
+
const indentOf = (raw: string): "tab" | number => (raw === "tab" ? "tab" : Number(raw));
|
|
9
|
+
const DEFAULT_OUT = defaultGrammarPath();
|
|
10
|
+
|
|
11
|
+
export const generateCmd = command("generate")
|
|
12
|
+
.description("Build the TextMate grammar from the tree-sitter-recipe vocabulary")
|
|
13
|
+
.flag("out", flag.string().alias("o").default(DEFAULT_OUT).describe("Output JSON path"))
|
|
14
|
+
.flag("indent", flag.enum(["tab", "2", "4"]).default("tab").describe("JSON indent"))
|
|
15
|
+
.flag("quiet", flag.boolean().alias("q").default(false).describe("Suppress stats on success"))
|
|
16
|
+
.action(({ flags, out }) => {
|
|
17
|
+
const { grammar, stats } = buildGrammar();
|
|
18
|
+
const serialized = serializeGrammar(grammar, indentOf(flags.indent));
|
|
19
|
+
const outAbs = resolve(cwd(), flags.out);
|
|
20
|
+
mkdirSync(dirname(outAbs), { recursive: true });
|
|
21
|
+
writeFileSync(outAbs, serialized);
|
|
22
|
+
const { json, jsonMode, log } = out;
|
|
23
|
+
|
|
24
|
+
if (jsonMode) {
|
|
25
|
+
json({ ok: true, outPath: outAbs, bytes: serialized.length, stats });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (flags.quiet) return;
|
|
29
|
+
|
|
30
|
+
log(`wrote ${outAbs}`);
|
|
31
|
+
log(` ${stats.topLevelPatterns} top-level patterns · ${serialized.length} bytes`);
|
|
32
|
+
const v = stats.vocab;
|
|
33
|
+
log(
|
|
34
|
+
` vocab: ${v.frequency} frequency · ${v.timing.single}+${v.timing.multi} timing · ${v.route.single}+${v.route.multi} route · ${v.dispensing.single}+${v.dispensing.multi} dispensing · ${v.forms.single}+${v.forms.multi} forms · ${v.compounding.single}+${v.compounding.multi} compounding · ${v.conditional.single}+${v.conditional.multi} conditional · ${v.warning} warning · ${v.units} units`,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defaultGrammarPath, packageDir } from "#bin/lib/utils.ts";
|
|
2
|
+
import { verify } from "#src/verifier.ts";
|
|
3
|
+
import { command, flag } from "@kjanat/dreamcli";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { cwd, exit } from "node:process";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_FIXTURES_DIR = resolve(packageDir("tree-sitter-recipe/package.json"), "test/highlight");
|
|
8
|
+
const DEFAULT_OUT = defaultGrammarPath();
|
|
9
|
+
|
|
10
|
+
export const verifyCmd = command("verify")
|
|
11
|
+
.description("Tokenize tree-sitter-recipe highlight fixtures and assert scope matches")
|
|
12
|
+
.flag("grammar", flag.string().alias("g").default(DEFAULT_OUT).describe("Path to .tmLanguage.json"))
|
|
13
|
+
.flag("fixtures", flag.string().alias("f").default(DEFAULT_FIXTURES_DIR).describe("Directory of .recipe fixtures"))
|
|
14
|
+
.flag("max-failures", flag.number().default(40).describe("Max failures to print (0 = all)"))
|
|
15
|
+
.action(async ({ flags, out }) => {
|
|
16
|
+
const result = await verify({
|
|
17
|
+
grammarPath: resolve(cwd(), flags.grammar),
|
|
18
|
+
fixturesDir: resolve(cwd(), flags.fixtures),
|
|
19
|
+
});
|
|
20
|
+
const failuresLen = result.failures.length;
|
|
21
|
+
const { json, jsonMode, setExitCode, log } = out;
|
|
22
|
+
|
|
23
|
+
if (jsonMode) {
|
|
24
|
+
json(result);
|
|
25
|
+
if (failuresLen > 0) {
|
|
26
|
+
setExitCode(1);
|
|
27
|
+
exit();
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
log(`${result.pass} / ${result.total} assertions pass`);
|
|
33
|
+
if (failuresLen === 0) return;
|
|
34
|
+
|
|
35
|
+
log("");
|
|
36
|
+
log("── failures ──");
|
|
37
|
+
const limit = flags["max-failures"] === 0 ? failuresLen : flags["max-failures"];
|
|
38
|
+
for (const f of result.failures.slice(0, limit)) {
|
|
39
|
+
const gotStr = f.got
|
|
40
|
+
? f.got.filter((s) => s !== "source.recipe").join(" · ") || "(root only)"
|
|
41
|
+
: "(no token)";
|
|
42
|
+
log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
|
|
43
|
+
}
|
|
44
|
+
if (failuresLen > limit) {
|
|
45
|
+
log(` … +${failuresLen - limit} more`);
|
|
46
|
+
}
|
|
47
|
+
setExitCode(1);
|
|
48
|
+
});
|
package/bin/lib/utils.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
const toPath = (resolved: string) =>
|
|
8
|
+
resolved.startsWith("file:")
|
|
9
|
+
? fileURLToPath(resolved)
|
|
10
|
+
: resolved;
|
|
11
|
+
|
|
12
|
+
export function packageDir(specifier: string): string {
|
|
13
|
+
return resolve(dirname(toPath(require.resolve(specifier))));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveImportMeta(specifier: string): string {
|
|
17
|
+
return toPath(import.meta.resolve(specifier));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default path of the generated grammar: `recipe.tmLanguage.json` at the
|
|
22
|
+
* package root. Anchored on `#pkg` (package.json / deno.json — always present)
|
|
23
|
+
* rather than `#tmLang`, because the grammar is generated + gitignored and may
|
|
24
|
+
* not exist yet on a fresh checkout, and `import.meta.resolve` throws on a
|
|
25
|
+
* missing target. Existence is enforced later, where the file is actually read.
|
|
26
|
+
*/
|
|
27
|
+
export function defaultGrammarPath(): string {
|
|
28
|
+
return resolve(dirname(resolveImportMeta("#pkg")), "recipe.tmLanguage.json");
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "recipe-tmlanguage",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "TextMate grammar for the recipe (.recipe) pharmacological notation language.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dreamcli",
|
|
@@ -21,17 +21,20 @@
|
|
|
21
21
|
},
|
|
22
22
|
"type": "module",
|
|
23
23
|
"imports": {
|
|
24
|
+
"#bin/*": "./bin/*",
|
|
25
|
+
"#src/*": "./src/*",
|
|
26
|
+
"#deps/*": "./src/deps/*",
|
|
24
27
|
"#pkg": "./package.json",
|
|
25
|
-
"#
|
|
26
|
-
"#verifier": "./src/verifier.ts"
|
|
28
|
+
"#tmLang": "./recipe.tmLanguage.json"
|
|
27
29
|
},
|
|
28
30
|
"exports": {
|
|
29
31
|
".": "./recipe.tmLanguage.json",
|
|
32
|
+
"./bin": "./bin/cli.mjs",
|
|
30
33
|
"./package.json": "./package.json"
|
|
31
34
|
},
|
|
32
35
|
"main": "./recipe.tmLanguage.json",
|
|
33
36
|
"module": "./recipe.tmLanguage.json",
|
|
34
|
-
"bin": "bin/
|
|
37
|
+
"bin": "bin/cli.mjs",
|
|
35
38
|
"directories": {
|
|
36
39
|
"lib": "/src",
|
|
37
40
|
"bin": "/bin"
|
|
@@ -52,15 +55,17 @@
|
|
|
52
55
|
"generate": "run recipe-tmlang generate",
|
|
53
56
|
"lint": "biome lint",
|
|
54
57
|
"prepack": "bun run generate && bun run bundle",
|
|
55
|
-
"recipe-tmlang": "bun bin/
|
|
58
|
+
"recipe-tmlang": "bun bin/cli.ts",
|
|
59
|
+
"test": "bun test",
|
|
56
60
|
"typecheck": "tsc --noEmit",
|
|
57
|
-
"verify": "run recipe-tmlang verify"
|
|
61
|
+
"verify": "run recipe-tmlang verify",
|
|
62
|
+
"version": "node bin/cli.mjs --version"
|
|
58
63
|
},
|
|
59
64
|
"dependencies": {
|
|
60
|
-
"dreamcli": "
|
|
65
|
+
"@kjanat/dreamcli": "^2.5.0",
|
|
61
66
|
"tree-sitter-recipe": "^0.3.1",
|
|
62
67
|
"vscode-oniguruma": "^2.0.1",
|
|
63
|
-
"vscode-textmate": "^9.2
|
|
68
|
+
"vscode-textmate": "^9.3.2"
|
|
64
69
|
},
|
|
65
70
|
"devDependencies": {
|
|
66
71
|
"@biomejs/biome": "^2.5.1",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/deps/oniguruma.ts
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import type { IOnigLib } from "./textmate.ts";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const oniguruma: typeof import("vscode-oniguruma") = require("vscode-oniguruma");
|
|
8
|
+
|
|
9
|
+
let loadPromise: Promise<void> | undefined;
|
|
10
|
+
|
|
11
|
+
export function loadOniguruma(): Promise<void> {
|
|
12
|
+
loadPromise ??= (async () => {
|
|
13
|
+
const wasmPath = require.resolve("vscode-oniguruma/release/onig.wasm");
|
|
14
|
+
const wasm = await readFile(wasmPath);
|
|
15
|
+
|
|
16
|
+
await oniguruma.loadWASM(wasm);
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
return loadPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function createOnigLib(): Promise<IOnigLib> {
|
|
23
|
+
await loadOniguruma();
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
createOnigScanner(patterns) {
|
|
27
|
+
return oniguruma.createOnigScanner(patterns);
|
|
28
|
+
},
|
|
29
|
+
createOnigString(string) {
|
|
30
|
+
return oniguruma.createOnigString(string);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const { loadWASM, createOnigScanner, createOnigString, OnigScanner, OnigString } = oniguruma;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
export type { IOnigLib, StateStack } from "vscode-textmate";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
const textmate: typeof import("vscode-textmate") = require("vscode-textmate");
|
|
7
|
+
|
|
8
|
+
export const { parseRawGrammar, Registry, INITIAL } = textmate;
|
package/src/grammar.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Scopes are standard TextMate names with a `.recipe` suffix so themes paint
|
|
7
7
|
* recipe blocks without a custom theme shipment.
|
|
8
|
+
*
|
|
9
|
+
* @module recipe-tmlanguage/src/grammar
|
|
8
10
|
*/
|
|
9
11
|
import { COUNTERS, NUMBER_WORDS, PERIOD_PLURALS, PERIODS } from "tree-sitter-recipe/grammar/dutch";
|
|
10
12
|
import {
|
|
@@ -74,7 +76,7 @@ const alt = (items: readonly string[]): string =>
|
|
|
74
76
|
const altMultiword = (items: readonly string[]): string =>
|
|
75
77
|
[...new Set(items)]
|
|
76
78
|
.sort((a, b) => b.length - a.length)
|
|
77
|
-
.map((s) => s
|
|
79
|
+
.map((s) => escapeRegex(s).replace(/\s+/g, "\\s+"))
|
|
78
80
|
.join("|");
|
|
79
81
|
|
|
80
82
|
// Word boundary that treats `.` as part of the token so `a.c.` doesn't match
|
package/src/verifier.ts
CHANGED
|
@@ -5,17 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* No CLI concerns here; the caller supplies paths and decides how to present
|
|
7
7
|
* the result (text table / JSON / exit code).
|
|
8
|
+
*
|
|
9
|
+
* @module recipe-tmlanguage/src/verifier
|
|
8
10
|
*/
|
|
9
11
|
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
-
import { createRequire } from "node:module";
|
|
11
12
|
import { resolve } from "node:path";
|
|
12
13
|
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const oniguruma: typeof import("vscode-oniguruma") = require("vscode-oniguruma");
|
|
17
|
-
const textmate: typeof import("vscode-textmate") = require("vscode-textmate");
|
|
18
|
-
const { parseRawGrammar, Registry } = textmate;
|
|
14
|
+
import { createOnigLib } from "#deps/oniguruma.ts";
|
|
15
|
+
import type { StateStack } from "#deps/textmate.ts";
|
|
16
|
+
import { parseRawGrammar, Registry } from "#deps/textmate.ts";
|
|
19
17
|
|
|
20
18
|
// ── capture → scope mapping (inverse of grammar.ts SCOPE) ───────────────────
|
|
21
19
|
// Fixtures speak tree-sitter capture names; the tokenizer speaks TextMate
|
|
@@ -57,7 +55,6 @@ export type VerifyResult = {
|
|
|
57
55
|
export type VerifyOptions = {
|
|
58
56
|
grammarPath: string;
|
|
59
57
|
fixturesDir: string;
|
|
60
|
-
onigWasmPath: string;
|
|
61
58
|
};
|
|
62
59
|
|
|
63
60
|
// ── fixture parser ──────────────────────────────────────────────────────────
|
|
@@ -105,19 +102,13 @@ function parseFixture(content: string, name: string): { source: string; asserts:
|
|
|
105
102
|
|
|
106
103
|
// ── main ────────────────────────────────────────────────────────────────────
|
|
107
104
|
export async function verify(opts: VerifyOptions): Promise<VerifyResult> {
|
|
108
|
-
const
|
|
109
|
-
await oniguruma.loadWASM(wasmBin.buffer as ArrayBuffer);
|
|
110
|
-
|
|
111
|
-
const onigLib = Promise.resolve({
|
|
112
|
-
createOnigScanner: (patterns: string[]) => new oniguruma.OnigScanner(patterns),
|
|
113
|
-
createOnigString: (s: string) => new oniguruma.OnigString(s),
|
|
114
|
-
});
|
|
105
|
+
const onigLib = createOnigLib();
|
|
115
106
|
|
|
116
107
|
const rawGrammar = parseRawGrammar(
|
|
117
108
|
readFileSync(opts.grammarPath, "utf-8"),
|
|
118
109
|
opts.grammarPath,
|
|
119
110
|
);
|
|
120
|
-
const registry = new Registry({ onigLib, loadGrammar:
|
|
111
|
+
const registry = new Registry({ onigLib, loadGrammar: () => Promise.resolve(null) });
|
|
121
112
|
const grammar = await registry.addGrammar(rawGrammar);
|
|
122
113
|
|
|
123
114
|
const result: VerifyResult = { pass: 0, total: 0, failures: [] };
|
package/bin/recipe-tmlang.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* recipe-tmlang — TextMate grammar generator & verifier for recipe-tmlanguage.
|
|
4
|
-
*
|
|
5
|
-
* Subcommands
|
|
6
|
-
* - generate: Build dist/recipe.tmLanguage.json from the tree-sitter-recipe vocab.
|
|
7
|
-
* - verify: Tokenize tree-sitter-recipe's highlight fixtures and assert scopes.
|
|
8
|
-
*
|
|
9
|
-
* Zero manual argparse — argument parsing, help, and completions all come from
|
|
10
|
-
* {@link https://github.com/kjanat/dreamcli | DreamCLI}. `--json` is a DreamCLI built-in;
|
|
11
|
-
* we branch on {@linkcode Out.jsonMode}.
|
|
12
|
-
*/
|
|
13
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { dirname, resolve } from "node:path";
|
|
15
|
-
import { cwd, exit } from "node:process";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
|
|
18
|
-
import { cli, command, flag } from "dreamcli";
|
|
19
|
-
import type { Out } from "dreamcli";
|
|
20
|
-
|
|
21
|
-
import { buildGrammar, serializeGrammar } from "#grammar";
|
|
22
|
-
import { verify } from "#verifier";
|
|
23
|
-
|
|
24
|
-
import { homepage, repository, version } from "#pkg" with { type: "json" };
|
|
25
|
-
|
|
26
|
-
const DEFAULT_OUT = `${resolve(import.meta.dirname, "..")}/recipe.tmLanguage.json`;
|
|
27
|
-
|
|
28
|
-
const TS_RX_DIR = resolve(dirname(fileURLToPath(import.meta.resolve("tree-sitter-recipe/package.json"))));
|
|
29
|
-
const DEFAULT_FIXTURES_DIR = resolve(TS_RX_DIR, "test/highlight");
|
|
30
|
-
const DEFAULT_ONIG_WASM = fileURLToPath(import.meta.resolve("vscode-oniguruma/release/onig.wasm"));
|
|
31
|
-
|
|
32
|
-
const indentOf = (raw: string): "tab" | number => (raw === "tab" ? "tab" : Number(raw));
|
|
33
|
-
|
|
34
|
-
const generate = command("generate")
|
|
35
|
-
.description("Build the TextMate grammar from the tree-sitter-recipe vocabulary")
|
|
36
|
-
.flag("out", flag.string().alias("o").default(DEFAULT_OUT).describe("Output JSON path"))
|
|
37
|
-
.flag("indent", flag.enum(["tab", "2", "4"]).default("tab").describe("JSON indent"))
|
|
38
|
-
.flag("quiet", flag.boolean().alias("q").default(false).describe("Suppress stats on success"))
|
|
39
|
-
.action(({ flags, out }) => {
|
|
40
|
-
const { grammar, stats } = buildGrammar();
|
|
41
|
-
const serialized = serializeGrammar(grammar, indentOf(flags.indent));
|
|
42
|
-
const outAbs = resolve(cwd(), flags.out);
|
|
43
|
-
mkdirSync(dirname(outAbs), { recursive: true });
|
|
44
|
-
writeFileSync(outAbs, serialized);
|
|
45
|
-
if (out.jsonMode) {
|
|
46
|
-
out.json({ ok: true, outPath: outAbs, bytes: serialized.length, stats });
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (flags.quiet) return;
|
|
50
|
-
|
|
51
|
-
out.log(`wrote ${outAbs}`);
|
|
52
|
-
out.log(` ${stats.topLevelPatterns} top-level patterns · ${serialized.length} bytes`);
|
|
53
|
-
const v = stats.vocab;
|
|
54
|
-
out.log(
|
|
55
|
-
` vocab: ${v.frequency} frequency · ${v.timing.single}+${v.timing.multi} timing · ${v.route.single}+${v.route.multi} route · ${v.dispensing.single}+${v.dispensing.multi} dispensing · ${v.forms.single}+${v.forms.multi} forms · ${v.compounding.single}+${v.compounding.multi} compounding · ${v.conditional.single}+${v.conditional.multi} conditional · ${v.warning} warning · ${v.units} units`,
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const verifyCmd = command("verify")
|
|
60
|
-
.description("Tokenize tree-sitter-recipe highlight fixtures and assert scope matches")
|
|
61
|
-
.flag("grammar", flag.string().alias("g").default(DEFAULT_OUT).describe("Path to .tmLanguage.json"))
|
|
62
|
-
.flag("fixtures", flag.string().alias("f").default(DEFAULT_FIXTURES_DIR).describe("Directory of .recipe fixtures"))
|
|
63
|
-
.flag("onig-wasm", flag.string().default(DEFAULT_ONIG_WASM).describe("Path to oniguruma WASM"))
|
|
64
|
-
.flag("max-failures", flag.number().default(40).describe("Max failures to print (0 = all)"))
|
|
65
|
-
.action(async ({ flags, out }) => {
|
|
66
|
-
const result = await verify({
|
|
67
|
-
grammarPath: resolve(cwd(), flags.grammar),
|
|
68
|
-
fixturesDir: resolve(cwd(), flags.fixtures),
|
|
69
|
-
onigWasmPath: resolve(cwd(), flags["onig-wasm"]),
|
|
70
|
-
});
|
|
71
|
-
const failuresLen = result.failures.length;
|
|
72
|
-
|
|
73
|
-
if (out.jsonMode) {
|
|
74
|
-
out.json(result);
|
|
75
|
-
if (failuresLen > 0) {
|
|
76
|
-
out.setExitCode(1);
|
|
77
|
-
exit();
|
|
78
|
-
}
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
out.log(`${result.pass} / ${result.total} assertions pass`);
|
|
83
|
-
if (failuresLen === 0) return;
|
|
84
|
-
|
|
85
|
-
out.log("");
|
|
86
|
-
out.log("── failures ──");
|
|
87
|
-
const limit = flags["max-failures"] === 0 ? failuresLen : flags["max-failures"];
|
|
88
|
-
for (const f of result.failures.slice(0, limit)) {
|
|
89
|
-
const gotStr = f.got
|
|
90
|
-
? f.got.filter((s) => s !== "source.recipe").join(" · ") || "(root only)"
|
|
91
|
-
: "(no token)";
|
|
92
|
-
out.log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
|
|
93
|
-
}
|
|
94
|
-
if (failuresLen > limit) {
|
|
95
|
-
out.log(` … +${failuresLen - limit} more`);
|
|
96
|
-
}
|
|
97
|
-
out.setExitCode(1);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
export const app = cli("recipe-tmlang").packageJson({ repository, homepage, version }).links()
|
|
101
|
-
.description("TextMate grammar generator & verifier for the recipe DSL")
|
|
102
|
-
.command(generate)
|
|
103
|
-
.command(verifyCmd)
|
|
104
|
-
.completions();
|
|
105
|
-
|
|
106
|
-
if (import.meta.main) app.run();
|