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.
@@ -1,13 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
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.replace(/\./g, "\\.").replace(/\s+/g, "\\s+")).join("|");
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 wasmBin = readFileSync(opts.onigWasmPath);
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: async () => null
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 package.json
384
- var version = "0.3.3";
385
- var homepage = "https://github.com/kjanat/recipe-tmlanguage#recipe-tmlanguage";
386
- var repository = {
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
- if (out.jsonMode) {
440
- out.json(result);
464
+ const { json, jsonMode, setExitCode, log } = out;
465
+ if (jsonMode) {
466
+ json(result);
441
467
  if (failuresLen > 0) {
442
- out.setExitCode(1);
468
+ setExitCode(1);
443
469
  exit();
444
470
  }
445
471
  return;
446
472
  }
447
- out.log(`${result.pass} / ${result.total} assertions pass`);
473
+ log(`${result.pass} / ${result.total} assertions pass`);
448
474
  if (failuresLen === 0) return;
449
- out.log("");
450
- out.log("── failures ──");
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
- out.log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
480
+ log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
455
481
  }
456
- if (failuresLen > limit) out.log(` … +${failuresLen - limit} more`);
457
- out.setExitCode(1);
482
+ if (failuresLen > limit) log(` … +${failuresLen - limit} more`);
483
+ setExitCode(1);
458
484
  });
459
- const app = cli("recipe-tmlang").packageJson({
460
- repository,
461
- homepage,
462
- version
463
- }).links().description("TextMate grammar generator & verifier for the recipe DSL").command(generate).command(verifyCmd).completions();
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 { app };
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
+ });
@@ -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",
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
- "#grammar": "./src/grammar.ts",
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/recipe-tmlang.mjs",
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/recipe-tmlang.ts",
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": "npm:@kjanat/dreamcli@^2.4.0",
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.0"
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.replace(/\./g, "\\.").replace(/\s+/g, "\\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 type { StateStack } from "vscode-textmate";
14
-
15
- const require = createRequire(import.meta.url);
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 wasmBin = readFileSync(opts.onigWasmPath);
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: async () => null });
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: [] };
@@ -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();