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.
- package/bin/commands/generate.ts +37 -0
- package/bin/commands/verify.ts +53 -0
- package/bin/recipe-tmlang.mjs +468 -0
- package/bin/recipe-tmlang.ts +7 -89
- package/package.json +4 -2
- package/src/grammar.ts +1 -1
- package/src/verifier.ts +1 -1
|
@@ -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 {};
|
package/bin/recipe-tmlang.ts
CHANGED
|
@@ -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
|
|
11
|
+
* we branch on {@linkcode out.jsonMode | https://dreamcli.kjanat.com/reference/symbols/main/Out#jsonmode}.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": "
|
|
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
|
|
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:
|
|
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: [] };
|