recipe-tmlanguage 0.3.2 → 0.3.4

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.
@@ -0,0 +1,37 @@
1
+ import { buildGrammar, serializeGrammar } from "#grammar";
2
+ import { command, flag } from "@kjanat/dreamcli";
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
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 require = createRequire(import.meta.url);
10
+ const DEFAULT_OUT = `${dirname(require.resolve("#pkg"))}/recipe.tmLanguage.json`;
11
+
12
+ export const generateCmd = command("generate")
13
+ .description("Build the TextMate grammar from the tree-sitter-recipe vocabulary")
14
+ .flag("out", flag.string().alias("o").default(DEFAULT_OUT).describe("Output JSON path"))
15
+ .flag("indent", flag.enum(["tab", "2", "4"]).default("tab").describe("JSON indent"))
16
+ .flag("quiet", flag.boolean().alias("q").default(false).describe("Suppress stats on success"))
17
+ .action(({ flags, out }) => {
18
+ const { grammar, stats } = buildGrammar();
19
+ const serialized = serializeGrammar(grammar, indentOf(flags.indent));
20
+ const outAbs = resolve(cwd(), flags.out);
21
+ mkdirSync(dirname(outAbs), { recursive: true });
22
+ writeFileSync(outAbs, serialized);
23
+ const { json, jsonMode, log } = out;
24
+
25
+ if (jsonMode) {
26
+ json({ ok: true, outPath: outAbs, bytes: serialized.length, stats });
27
+ return;
28
+ }
29
+ if (flags.quiet) return;
30
+
31
+ log(`wrote ${outAbs}`);
32
+ log(` ${stats.topLevelPatterns} top-level patterns · ${serialized.length} bytes`);
33
+ const v = stats.vocab;
34
+ log(
35
+ ` 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`,
36
+ );
37
+ });
@@ -0,0 +1,53 @@
1
+ import { verify } from "#verifier";
2
+ import { command, flag } from "@kjanat/dreamcli";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, resolve } from "node:path";
5
+ import { cwd, exit } from "node:process";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const TS_RX_DIR = resolve(dirname(require.resolve("tree-sitter-recipe/package.json")));
9
+ const DEFAULT_FIXTURES_DIR = resolve(TS_RX_DIR, "test/highlight");
10
+ const DEFAULT_ONIG_WASM = require.resolve("vscode-oniguruma/release/onig.wasm");
11
+ const DEFAULT_OUT = `${dirname(require.resolve("#pkg"))}/recipe.tmLanguage.json`;
12
+
13
+ export const verifyCmd = command("verify")
14
+ .description("Tokenize tree-sitter-recipe highlight fixtures and assert scope matches")
15
+ .flag("grammar", flag.string().alias("g").default(DEFAULT_OUT).describe("Path to .tmLanguage.json"))
16
+ .flag("fixtures", flag.string().alias("f").default(DEFAULT_FIXTURES_DIR).describe("Directory of .recipe fixtures"))
17
+ .flag("onig-wasm", flag.string().default(DEFAULT_ONIG_WASM).describe("Path to oniguruma WASM"))
18
+ .flag("max-failures", flag.number().default(40).describe("Max failures to print (0 = all)"))
19
+ .action(async ({ flags, out }) => {
20
+ const result = await verify({
21
+ grammarPath: resolve(cwd(), flags.grammar),
22
+ fixturesDir: resolve(cwd(), flags.fixtures),
23
+ onigWasmPath: resolve(cwd(), flags["onig-wasm"]),
24
+ });
25
+ const failuresLen = result.failures.length;
26
+ const { json, jsonMode, setExitCode, log } = out;
27
+
28
+ if (jsonMode) {
29
+ json(result);
30
+ if (failuresLen > 0) {
31
+ setExitCode(1);
32
+ exit();
33
+ }
34
+ return;
35
+ }
36
+
37
+ log(`${result.pass} / ${result.total} assertions pass`);
38
+ if (failuresLen === 0) return;
39
+
40
+ log("");
41
+ log("── failures ──");
42
+ const limit = flags["max-failures"] === 0 ? failuresLen : flags["max-failures"];
43
+ for (const f of result.failures.slice(0, limit)) {
44
+ const gotStr = f.got
45
+ ? f.got.filter((s) => s !== "source.recipe").join(" · ") || "(root only)"
46
+ : "(no token)";
47
+ log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
48
+ }
49
+ if (failuresLen > limit) {
50
+ log(` … +${failuresLen - limit} more`);
51
+ }
52
+ setExitCode(1);
53
+ });
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { COUNTERS, NUMBER_WORDS, PERIODS, PERIOD_PLURALS } from "tree-sitter-recipe/grammar/dutch";
4
+ 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";
5
+ import { UNITS } from "tree-sitter-recipe/grammar/units";
6
+ import { cli, command, flag } from "@kjanat/dreamcli";
7
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { cwd, exit } from "node:process";
10
+ //#region src/grammar.ts
11
+ /**
12
+ * @file Pure grammar builder — imports the tree-sitter-recipe vocabulary and
13
+ * compiles it into a TextMate grammar object. No filesystem I/O; the CLI
14
+ * handles serialization and writes.
15
+ *
16
+ * Scopes are standard TextMate names with a `.recipe` suffix so themes paint
17
+ * recipe blocks without a custom theme shipment.
18
+ */
19
+ const SCOPE = {
20
+ rxMarker: "keyword.control.directive.rx.recipe",
21
+ dispenseMarker: "keyword.control.directive.dispense.recipe",
22
+ signaMarker: "keyword.control.directive.signa.recipe",
23
+ frequency: "keyword.other.frequency.recipe",
24
+ timing: "keyword.other.timing.recipe",
25
+ route: "support.function.route.recipe",
26
+ dispensing: "entity.other.attribute-name.recipe",
27
+ warning: "invalid.illegal.warning.recipe",
28
+ form: "storage.type.form.recipe",
29
+ compounding: "keyword.operator.compounding.recipe",
30
+ conditional: "keyword.control.conditional.recipe",
31
+ fillMarker: "keyword.operator.fill.recipe",
32
+ dtdKeyword: "keyword.operator.dtd.recipe",
33
+ number: "constant.numeric.recipe",
34
+ unit: "support.type.unit.recipe",
35
+ lineComment: "comment.line.number-sign.recipe",
36
+ docCommentLine: "comment.line.documentation.recipe",
37
+ blockComment: "comment.block.recipe",
38
+ docCommentBlock: "comment.block.documentation.recipe",
39
+ punctuation: "punctuation.separator.recipe",
40
+ ingredientWord: "variable.other.ingredient.recipe",
41
+ signaWord: "string.unquoted.signa.recipe",
42
+ dispenseWord: "variable.other.dispense.recipe"
43
+ };
44
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
45
+ const escapeRegex = (s) => s.replace(REGEX_METACHARS, "\\$&");
46
+ const alt = (items) => [...new Set(items)].sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
47
+ const altMultiword = (items) => [...new Set(items)].sort((a, b) => b.length - a.length).map((s) => escapeRegex(s).replace(/\s+/g, "\\s+")).join("|");
48
+ const wb = (pattern) => `(?<![\\w.])(?:${pattern})(?![\\w.])`;
49
+ function buildGrammar() {
50
+ const doseMatch = {
51
+ match: `(\\d+(?:[.,]\\d+)?)\\s*(${alt(UNITS)})(?![A-Za-zÀ-ÿ])`,
52
+ captures: {
53
+ "1": { name: SCOPE.number },
54
+ "2": { name: SCOPE.unit }
55
+ }
56
+ };
57
+ const bareNumber = {
58
+ match: "\\d+(?:[.,]\\d+)?",
59
+ name: SCOPE.number
60
+ };
61
+ const compactFrequency = {
62
+ match: "[1-9]\\s*dd(?![A-Za-zÀ-ÿ0-9])",
63
+ name: SCOPE.frequency
64
+ };
65
+ const fillTo = {
66
+ match: "\\bad\\b(?=\\s+\\d)",
67
+ name: SCOPE.fillMarker
68
+ };
69
+ const dtdDirective = {
70
+ match: "(?i)(?<![\\w.])(d\\.?t\\.?d\\.?)(?:\\s+(no))?(?=\\s+\\d)",
71
+ captures: {
72
+ "1": { name: SCOPE.dtdKeyword },
73
+ "2": { name: SCOPE.dtdKeyword }
74
+ }
75
+ };
76
+ const warningAbbrev = {
77
+ match: wb(alt(WARNING)),
78
+ name: SCOPE.warning
79
+ };
80
+ const latinAbbrevs = [
81
+ {
82
+ match: wb(altMultiword(TIMING_MULTIWORD)),
83
+ name: SCOPE.timing
84
+ },
85
+ {
86
+ match: wb(altMultiword(ROUTE_MULTIWORD)),
87
+ name: SCOPE.route
88
+ },
89
+ {
90
+ match: wb(altMultiword(DISPENSING_MULTIWORD)),
91
+ name: SCOPE.dispensing
92
+ },
93
+ {
94
+ match: wb(altMultiword(FORMS_MULTIWORD)),
95
+ name: SCOPE.form
96
+ },
97
+ {
98
+ match: wb(altMultiword(COMPOUNDING_MULTIWORD)),
99
+ name: SCOPE.compounding
100
+ },
101
+ {
102
+ match: wb(altMultiword(CONDITIONAL_MULTIWORD)),
103
+ name: SCOPE.conditional
104
+ },
105
+ {
106
+ match: wb(alt(FREQUENCY)),
107
+ name: SCOPE.frequency
108
+ },
109
+ {
110
+ match: wb(alt(TIMING)),
111
+ name: SCOPE.timing
112
+ },
113
+ {
114
+ match: wb(alt(ROUTE)),
115
+ name: SCOPE.route
116
+ },
117
+ {
118
+ match: wb(alt(DISPENSING)),
119
+ name: SCOPE.dispensing
120
+ },
121
+ {
122
+ match: wb(alt(FORMS)),
123
+ name: SCOPE.form
124
+ },
125
+ {
126
+ match: wb(alt(COMPOUNDING)),
127
+ name: SCOPE.compounding
128
+ },
129
+ {
130
+ match: wb(alt(CONDITIONAL)),
131
+ name: SCOPE.conditional
132
+ }
133
+ ];
134
+ const punctuation = {
135
+ match: "[-.,;:()]",
136
+ name: SCOPE.punctuation
137
+ };
138
+ const comments = [
139
+ {
140
+ name: SCOPE.docCommentBlock,
141
+ begin: "/\\*\\*",
142
+ end: "\\*/"
143
+ },
144
+ {
145
+ name: SCOPE.blockComment,
146
+ begin: "/\\*",
147
+ end: "\\*/"
148
+ },
149
+ {
150
+ name: SCOPE.docCommentLine,
151
+ match: "#!.*$"
152
+ },
153
+ {
154
+ name: SCOPE.lineComment,
155
+ match: "#.*$"
156
+ }
157
+ ];
158
+ const period = alt(PERIODS);
159
+ const dutchFrequency = [
160
+ {
161
+ match: `(?i)\\bom[ \\t]+de(?:[ \\t]+andere)?(?:[ \\t]+\\d+)?[ \\t]+(?:${alt([...PERIOD_PLURALS, ...PERIODS])})\\b`,
162
+ name: SCOPE.frequency
163
+ },
164
+ {
165
+ match: `(?i)\\b\\d+[ \\t]*(?:${alt(COUNTERS)})[ \\t]+(?:per[ \\t]+(?:${period})|daags)\\b`,
166
+ name: SCOPE.frequency
167
+ },
168
+ {
169
+ match: `(?i)\\b(?:${alt(NUMBER_WORDS)})[ \\t]*maal(?:[ \\t]+(?:daags|per[ \\t]+(?:${period})))?\\b`,
170
+ name: SCOPE.frequency
171
+ }
172
+ ];
173
+ const sharedAtoms = [
174
+ ...comments,
175
+ warningAbbrev,
176
+ dtdDirective,
177
+ fillTo,
178
+ compactFrequency,
179
+ ...dutchFrequency,
180
+ doseMatch,
181
+ ...latinAbbrevs,
182
+ bareNumber,
183
+ punctuation
184
+ ];
185
+ /**
186
+ * Sections end only at the literal next marker (R/, Da/, D/, S/) or EOF.
187
+ * The trailing slash is load-bearing: without it, `s\b` inside `s.o.s.`
188
+ * would spuriously close a signa section because `.` is non-word.
189
+ */
190
+ const nextSection = "(?i)(?=R/|Da?/|S/)|\\z";
191
+ const makeSection = (begin, marker, wordScope) => ({
192
+ name: `meta.section.${wordScope.split(".")[2] ?? "unknown"}.recipe`,
193
+ begin,
194
+ beginCaptures: { "0": { name: marker } },
195
+ end: nextSection,
196
+ patterns: [...sharedAtoms, {
197
+ match: "[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ0-9\\-]*",
198
+ name: wordScope
199
+ }]
200
+ });
201
+ const rxSection = makeSection("(?i)R/", SCOPE.rxMarker, SCOPE.ingredientWord);
202
+ const dispenseSection = makeSection("(?i)Da?/", SCOPE.dispenseMarker, SCOPE.dispenseWord);
203
+ const signaSection = makeSection("(?i)S/", SCOPE.signaMarker, SCOPE.signaWord);
204
+ const grammar = {
205
+ $schema: "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
206
+ name: "Recipe",
207
+ scopeName: "source.recipe",
208
+ fileTypes: ["recipe"],
209
+ patterns: [
210
+ ...comments,
211
+ rxSection,
212
+ dispenseSection,
213
+ signaSection,
214
+ warningAbbrev
215
+ ],
216
+ repository: {
217
+ comments: { patterns: comments },
218
+ "shared-atoms": { patterns: sharedAtoms }
219
+ }
220
+ };
221
+ return {
222
+ grammar,
223
+ stats: {
224
+ topLevelPatterns: countPatterns(grammar.patterns),
225
+ vocab: {
226
+ frequency: FREQUENCY.length,
227
+ timing: {
228
+ single: TIMING.length,
229
+ multi: TIMING_MULTIWORD.length
230
+ },
231
+ route: {
232
+ single: ROUTE.length,
233
+ multi: ROUTE_MULTIWORD.length
234
+ },
235
+ dispensing: {
236
+ single: DISPENSING.length,
237
+ multi: DISPENSING_MULTIWORD.length
238
+ },
239
+ forms: {
240
+ single: FORMS.length,
241
+ multi: FORMS_MULTIWORD.length
242
+ },
243
+ compounding: {
244
+ single: COMPOUNDING.length,
245
+ multi: COMPOUNDING_MULTIWORD.length
246
+ },
247
+ conditional: {
248
+ single: CONDITIONAL.length,
249
+ multi: CONDITIONAL_MULTIWORD.length
250
+ },
251
+ warning: WARNING.length,
252
+ units: UNITS.length
253
+ }
254
+ }
255
+ };
256
+ }
257
+ function countPatterns(patterns) {
258
+ let n = 0;
259
+ for (const p of patterns) {
260
+ n += 1;
261
+ if ("patterns" in p && p.patterns) n += countPatterns(p.patterns);
262
+ }
263
+ return n;
264
+ }
265
+ function serializeGrammar(g, indent) {
266
+ return `${JSON.stringify(g, null, indent === "tab" ? " " : indent)}\n`;
267
+ }
268
+ //#endregion
269
+ //#region bin/commands/generate.ts
270
+ const indentOf = (raw) => raw === "tab" ? "tab" : Number(raw);
271
+ const DEFAULT_OUT$1 = `${dirname(createRequire(import.meta.url).resolve("#pkg"))}/recipe.tmLanguage.json`;
272
+ 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([
273
+ "tab",
274
+ "2",
275
+ "4"
276
+ ]).default("tab").describe("JSON indent")).flag("quiet", flag.boolean().alias("q").default(false).describe("Suppress stats on success")).action(({ flags, out }) => {
277
+ const { grammar, stats } = buildGrammar();
278
+ const serialized = serializeGrammar(grammar, indentOf(flags.indent));
279
+ const outAbs = resolve(cwd(), flags.out);
280
+ mkdirSync(dirname(outAbs), { recursive: true });
281
+ writeFileSync(outAbs, serialized);
282
+ const { json, jsonMode, log } = out;
283
+ if (jsonMode) {
284
+ json({
285
+ ok: true,
286
+ outPath: outAbs,
287
+ bytes: serialized.length,
288
+ stats
289
+ });
290
+ return;
291
+ }
292
+ if (flags.quiet) return;
293
+ log(`wrote ${outAbs}`);
294
+ log(` ${stats.topLevelPatterns} top-level patterns · ${serialized.length} bytes`);
295
+ const v = stats.vocab;
296
+ 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`);
297
+ });
298
+ //#endregion
299
+ //#region src/verifier.ts
300
+ /**
301
+ * @file Pure verifier — tokenizes tree-sitter-recipe's own highlight fixtures
302
+ * with the generated TextMate grammar and reports whether each caret assertion
303
+ * lands on a matching scope.
304
+ *
305
+ * No CLI concerns here; the caller supplies paths and decides how to present
306
+ * the result (text table / JSON / exit code).
307
+ */
308
+ const require$1 = createRequire(import.meta.url);
309
+ const oniguruma = require$1("vscode-oniguruma");
310
+ const { parseRawGrammar, Registry } = require$1("vscode-textmate");
311
+ const CAPTURE_EXPECTS = {
312
+ "keyword.directive": "keyword.control.directive",
313
+ "keyword.repeat": "keyword.other.frequency",
314
+ "keyword.error": "invalid.illegal.warning",
315
+ "keyword.operator": "keyword.operator",
316
+ "keyword.conditional": "keyword.control.conditional",
317
+ "keyword": "keyword.other.timing",
318
+ "function.macro": "support.function.route",
319
+ "attribute": "entity.other.attribute-name",
320
+ "type": "storage.type.form",
321
+ "type.builtin": "support.type.unit",
322
+ "number": "constant.numeric",
323
+ "variable": "variable.other.ingredient",
324
+ "string": "string.unquoted.signa",
325
+ "comment": "comment",
326
+ "comment.documentation": "comment",
327
+ "punctuation.delimiter": "punctuation.separator"
328
+ };
329
+ const ASSERT_RE = /^\s*#\s*(<-|\^+)\s+([\w.]+)\s*$/;
330
+ const COMMENT_ONLY_RE = /^\s*#/;
331
+ function parseFixture(content, name) {
332
+ const rawLines = content.split(/\r?\n/);
333
+ const sourceLines = [];
334
+ const asserts = [];
335
+ const sourceLineIndexForRawLine = [];
336
+ for (const raw of rawLines) if (!COMMENT_ONLY_RE.test(raw)) {
337
+ sourceLines.push(raw);
338
+ sourceLineIndexForRawLine.push(sourceLines.length);
339
+ } else sourceLineIndexForRawLine.push(sourceLines.length);
340
+ for (let i = 0; i < rawLines.length; i++) {
341
+ const raw = rawLines[i] ?? "";
342
+ if (!COMMENT_ONLY_RE.test(raw)) continue;
343
+ const match = raw.match(ASSERT_RE);
344
+ if (!match) continue;
345
+ const [, kind, capture] = match;
346
+ if (!kind || !capture) continue;
347
+ const targetLine = sourceLineIndexForRawLine[i] ?? 0;
348
+ if (targetLine === 0) continue;
349
+ const col = kind === "<-" ? 0 : raw.indexOf("^");
350
+ asserts.push({
351
+ fixture: name,
352
+ targetLine,
353
+ col,
354
+ capture
355
+ });
356
+ }
357
+ return {
358
+ source: sourceLines.join("\n"),
359
+ asserts
360
+ };
361
+ }
362
+ async function verify(opts) {
363
+ const wasmBin = readFileSync(opts.onigWasmPath);
364
+ await oniguruma.loadWASM(wasmBin.buffer);
365
+ const onigLib = Promise.resolve({
366
+ createOnigScanner: (patterns) => new oniguruma.OnigScanner(patterns),
367
+ createOnigString: (s) => new oniguruma.OnigString(s)
368
+ });
369
+ const rawGrammar = parseRawGrammar(readFileSync(opts.grammarPath, "utf-8"), opts.grammarPath);
370
+ const grammar = await new Registry({
371
+ onigLib,
372
+ loadGrammar: () => Promise.resolve(null)
373
+ }).addGrammar(rawGrammar);
374
+ const result = {
375
+ pass: 0,
376
+ total: 0,
377
+ failures: []
378
+ };
379
+ for (const name of readdirSync(opts.fixturesDir).sort()) {
380
+ if (!name.endsWith(".recipe")) continue;
381
+ const { source, asserts } = parseFixture(readFileSync(resolve(opts.fixturesDir, name), "utf-8"), name);
382
+ const sourceLines = source.split("\n");
383
+ let ruleStack = null;
384
+ const perLine = [];
385
+ for (const line of sourceLines) {
386
+ const r = grammar.tokenizeLine(line, ruleStack);
387
+ perLine.push(r.tokens.map((t) => ({
388
+ start: t.startIndex,
389
+ end: t.endIndex,
390
+ scopes: [...t.scopes]
391
+ })));
392
+ ruleStack = r.ruleStack;
393
+ }
394
+ for (const a of asserts) {
395
+ result.total += 1;
396
+ const tokens = perLine[a.targetLine - 1];
397
+ const hit = tokens?.find((t) => a.col >= t.start && a.col < t.end) ?? tokens?.find((t) => a.col === t.end);
398
+ const expected = CAPTURE_EXPECTS[a.capture];
399
+ if (!!(hit && expected && hit.scopes.some((s) => s.startsWith(expected)))) result.pass += 1;
400
+ else result.failures.push({
401
+ fixture: a.fixture,
402
+ line: a.targetLine,
403
+ col: a.col,
404
+ capture: a.capture,
405
+ got: hit ? hit.scopes : null
406
+ });
407
+ }
408
+ }
409
+ return result;
410
+ }
411
+ //#endregion
412
+ //#region bin/commands/verify.ts
413
+ const require = createRequire(import.meta.url);
414
+ const DEFAULT_FIXTURES_DIR = resolve(resolve(dirname(require.resolve("tree-sitter-recipe/package.json"))), "test/highlight");
415
+ const DEFAULT_ONIG_WASM = require.resolve("vscode-oniguruma/release/onig.wasm");
416
+ const DEFAULT_OUT = `${dirname(require.resolve("#pkg"))}/recipe.tmLanguage.json`;
417
+ 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 }) => {
418
+ const result = await verify({
419
+ grammarPath: resolve(cwd(), flags.grammar),
420
+ fixturesDir: resolve(cwd(), flags.fixtures),
421
+ onigWasmPath: resolve(cwd(), flags["onig-wasm"])
422
+ });
423
+ const failuresLen = result.failures.length;
424
+ const { json, jsonMode, setExitCode, log } = out;
425
+ if (jsonMode) {
426
+ json(result);
427
+ if (failuresLen > 0) {
428
+ setExitCode(1);
429
+ exit();
430
+ }
431
+ return;
432
+ }
433
+ log(`${result.pass} / ${result.total} assertions pass`);
434
+ if (failuresLen === 0) return;
435
+ log("");
436
+ log("── failures ──");
437
+ const limit = flags["max-failures"] === 0 ? failuresLen : flags["max-failures"];
438
+ for (const f of result.failures.slice(0, limit)) {
439
+ const gotStr = f.got ? f.got.filter((s) => s !== "source.recipe").join(" · ") || "(root only)" : "(no token)";
440
+ log(` ${f.fixture}:${f.line}:${f.col} expected ${f.capture} got [${gotStr}]`);
441
+ }
442
+ if (failuresLen > limit) log(` … +${failuresLen - limit} more`);
443
+ setExitCode(1);
444
+ });
445
+ //#endregion
446
+ //#region bin/recipe-tmlang.ts
447
+ /**
448
+ * recipe-tmlang — TextMate grammar generator & verifier for recipe-tmlanguage.
449
+ *
450
+ * Subcommands
451
+ * - generate: Build dist/recipe.tmLanguage.json from the tree-sitter-recipe vocab.
452
+ * - verify: Tokenize tree-sitter-recipe's highlight fixtures and assert scopes.
453
+ *
454
+ * Zero manual argparse — argument parsing, help, and completions all come from
455
+ * {@link https://github.com/kjanat/dreamcli | DreamCLI}. `--json` is a DreamCLI built-in;
456
+ * we branch on {@linkcode out.jsonMode | https://dreamcli.kjanat.com/reference/symbols/main/Out#jsonmode}.
457
+ */
458
+ const app = cli("recipe-tmlanguage").packageJson({
459
+ repository: {
460
+ "type": "git",
461
+ "url": "git+https://github.com/kjanat/recipe-tmlanguage.git"
462
+ },
463
+ homepage: "https://github.com/kjanat/recipe-tmlanguage#recipe-tmlanguage",
464
+ version: "0.3.4"
465
+ }).links().description("TextMate grammar generator & verifier for the recipe DSL").command(generateCmd).command(verifyCmd).completions();
466
+ if (import.meta.main) app.run();
467
+ //#endregion
468
+ export {};
@@ -8,98 +8,16 @@
8
8
  *
9
9
  * Zero manual argparse — argument parsing, help, and completions all come from
10
10
  * {@link https://github.com/kjanat/dreamcli | DreamCLI}. `--json` is a DreamCLI built-in;
11
- * we branch on {@linkcode Out.jsonMode}.
11
+ * we branch on {@linkcode out.jsonMode | https://dreamcli.kjanat.com/reference/symbols/main/Out#jsonmode}.
12
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";
13
+ import { generateCmd } from "#bin/commands/generate";
14
+ import { verifyCmd } from "#bin/commands/verify";
15
+ import { homepage, name, repository, version } from "#pkg" with { type: "json" };
16
+ import { cli } from "@kjanat/dreamcli";
17
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()
18
+ const app = cli(name).packageJson({ repository, homepage, version }).links()
101
19
  .description("TextMate grammar generator & verifier for the recipe DSL")
102
- .command(generate)
20
+ .command(generateCmd)
103
21
  .command(verifyCmd)
104
22
  .completions();
105
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recipe-tmlanguage",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "TextMate grammar for the recipe (.recipe) pharmacological notation language.",
5
5
  "keywords": [
6
6
  "dreamcli",
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "type": "module",
23
23
  "imports": {
24
+ "#bin/*": "./bin/*.ts",
24
25
  "#pkg": "./package.json",
25
26
  "#grammar": "./src/grammar.ts",
26
27
  "#verifier": "./src/verifier.ts"
@@ -53,11 +54,12 @@
53
54
  "lint": "biome lint",
54
55
  "prepack": "bun run generate && bun run bundle",
55
56
  "recipe-tmlang": "bun bin/recipe-tmlang.ts",
57
+ "test": "bun test",
56
58
  "typecheck": "tsc --noEmit",
57
59
  "verify": "run recipe-tmlang verify"
58
60
  },
59
61
  "dependencies": {
60
- "dreamcli": "npm:@kjanat/dreamcli@^2.4.0",
62
+ "@kjanat/dreamcli": "^2.4.1",
61
63
  "tree-sitter-recipe": "^0.3.1",
62
64
  "vscode-oniguruma": "^2.0.1",
63
65
  "vscode-textmate": "^9.2.0"
package/src/grammar.ts CHANGED
@@ -74,7 +74,7 @@ const alt = (items: readonly string[]): string =>
74
74
  const altMultiword = (items: readonly string[]): string =>
75
75
  [...new Set(items)]
76
76
  .sort((a, b) => b.length - a.length)
77
- .map((s) => s.replace(/\./g, "\\.").replace(/\s+/g, "\\s+"))
77
+ .map((s) => escapeRegex(s).replace(/\s+/g, "\\s+"))
78
78
  .join("|");
79
79
 
80
80
  // Word boundary that treats `.` as part of the token so `a.c.` doesn't match
package/src/verifier.ts CHANGED
@@ -117,7 +117,7 @@ export async function verify(opts: VerifyOptions): Promise<VerifyResult> {
117
117
  readFileSync(opts.grammarPath, "utf-8"),
118
118
  opts.grammarPath,
119
119
  );
120
- const registry = new Registry({ onigLib, loadGrammar: async () => null });
120
+ const registry = new Registry({ onigLib, loadGrammar: () => Promise.resolve(null) });
121
121
  const grammar = await registry.addGrammar(rawGrammar);
122
122
 
123
123
  const result: VerifyResult = { pass: 0, total: 0, failures: [] };