recipe-tmlanguage 0.3.0
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/LICENSE +21 -0
- package/README.md +142 -0
- package/bin/recipe-tmlang.ts +107 -0
- package/package.json +82 -0
- package/recipe.tmLanguage.json +604 -0
- package/recipe.tmLanguage.ts +10 -0
- package/src/grammar.ts +332 -0
- package/src/verifier.ts +168 -0
package/src/grammar.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Pure grammar builder — imports the tree-sitter-recipe vocabulary and
|
|
3
|
+
* compiles it into a TextMate grammar object. No filesystem I/O; the CLI
|
|
4
|
+
* handles serialization and writes.
|
|
5
|
+
*
|
|
6
|
+
* Scopes are standard TextMate names with a `.recipe` suffix so themes paint
|
|
7
|
+
* recipe blocks without a custom theme shipment.
|
|
8
|
+
*/
|
|
9
|
+
import { COUNTERS, NUMBER_WORDS, PERIOD_PLURALS, PERIODS } from "tree-sitter-recipe/grammar/dutch";
|
|
10
|
+
import {
|
|
11
|
+
COMPOUNDING,
|
|
12
|
+
COMPOUNDING_MULTIWORD,
|
|
13
|
+
CONDITIONAL,
|
|
14
|
+
CONDITIONAL_MULTIWORD,
|
|
15
|
+
DISPENSING,
|
|
16
|
+
DISPENSING_MULTIWORD,
|
|
17
|
+
FORMS,
|
|
18
|
+
FORMS_MULTIWORD,
|
|
19
|
+
FREQUENCY,
|
|
20
|
+
ROUTE,
|
|
21
|
+
ROUTE_MULTIWORD,
|
|
22
|
+
TIMING,
|
|
23
|
+
TIMING_MULTIWORD,
|
|
24
|
+
WARNING,
|
|
25
|
+
} from "tree-sitter-recipe/grammar/latin";
|
|
26
|
+
import { UNITS } from "tree-sitter-recipe/grammar/units";
|
|
27
|
+
|
|
28
|
+
// scope map
|
|
29
|
+
export const SCOPE = {
|
|
30
|
+
rxMarker: "keyword.control.directive.rx.recipe",
|
|
31
|
+
dispenseMarker: "keyword.control.directive.dispense.recipe",
|
|
32
|
+
signaMarker: "keyword.control.directive.signa.recipe",
|
|
33
|
+
|
|
34
|
+
frequency: "keyword.other.frequency.recipe",
|
|
35
|
+
timing: "keyword.other.timing.recipe",
|
|
36
|
+
route: "support.function.route.recipe",
|
|
37
|
+
dispensing: "entity.other.attribute-name.recipe",
|
|
38
|
+
warning: "invalid.illegal.warning.recipe",
|
|
39
|
+
form: "storage.type.form.recipe",
|
|
40
|
+
compounding: "keyword.operator.compounding.recipe",
|
|
41
|
+
conditional: "keyword.control.conditional.recipe",
|
|
42
|
+
|
|
43
|
+
fillMarker: "keyword.operator.fill.recipe",
|
|
44
|
+
dtdKeyword: "keyword.operator.dtd.recipe",
|
|
45
|
+
|
|
46
|
+
number: "constant.numeric.recipe",
|
|
47
|
+
unit: "support.type.unit.recipe",
|
|
48
|
+
|
|
49
|
+
lineComment: "comment.line.number-sign.recipe",
|
|
50
|
+
docCommentLine: "comment.line.documentation.recipe",
|
|
51
|
+
blockComment: "comment.block.recipe",
|
|
52
|
+
docCommentBlock: "comment.block.documentation.recipe",
|
|
53
|
+
|
|
54
|
+
punctuation: "punctuation.separator.recipe",
|
|
55
|
+
|
|
56
|
+
ingredientWord: "variable.other.ingredient.recipe",
|
|
57
|
+
signaWord: "string.unquoted.signa.recipe",
|
|
58
|
+
dispenseWord: "variable.other.dispense.recipe",
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
// regex helpers
|
|
62
|
+
// TextMate uses Oniguruma — first-match, not longest-match like tree-sitter —
|
|
63
|
+
// so we always sort alternatives longest-first before joining with `|`.
|
|
64
|
+
const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
|
|
65
|
+
|
|
66
|
+
const escapeRegex = (s: string): string => s.replace(REGEX_METACHARS, "\\$&");
|
|
67
|
+
|
|
68
|
+
const alt = (items: readonly string[]): string =>
|
|
69
|
+
[...new Set(items)]
|
|
70
|
+
.sort((a, b) => b.length - a.length)
|
|
71
|
+
.map(escapeRegex)
|
|
72
|
+
.join("|");
|
|
73
|
+
|
|
74
|
+
const altMultiword = (items: readonly string[]): string =>
|
|
75
|
+
[...new Set(items)]
|
|
76
|
+
.sort((a, b) => b.length - a.length)
|
|
77
|
+
.map((s) => s.replace(/\./g, "\\.").replace(/\s+/g, "\\s+"))
|
|
78
|
+
.join("|");
|
|
79
|
+
|
|
80
|
+
// Word boundary that treats `.` as part of the token so `a.c.` doesn't match
|
|
81
|
+
// inside `a.c.e.`. `\b` alone is not enough because `.` is non-word.
|
|
82
|
+
const wb = (pattern: string): string => `(?<![\\w.])(?:${pattern})(?![\\w.])`;
|
|
83
|
+
|
|
84
|
+
// types
|
|
85
|
+
type Capture = { name?: string; patterns?: Pattern[] };
|
|
86
|
+
type Captures = Record<string, Capture>;
|
|
87
|
+
export type Pattern =
|
|
88
|
+
| { include: string }
|
|
89
|
+
| { name?: string; match: string; captures?: Captures }
|
|
90
|
+
| {
|
|
91
|
+
name?: string;
|
|
92
|
+
begin: string;
|
|
93
|
+
end: string;
|
|
94
|
+
beginCaptures?: Captures;
|
|
95
|
+
endCaptures?: Captures;
|
|
96
|
+
patterns?: Pattern[];
|
|
97
|
+
contentName?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type Grammar = {
|
|
101
|
+
$schema?: string;
|
|
102
|
+
name: string;
|
|
103
|
+
scopeName: string;
|
|
104
|
+
fileTypes: string[];
|
|
105
|
+
patterns: Pattern[];
|
|
106
|
+
repository: Record<string, { patterns: Pattern[] } | Pattern>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type VocabStats = {
|
|
110
|
+
frequency: number;
|
|
111
|
+
timing: { single: number; multi: number };
|
|
112
|
+
route: { single: number; multi: number };
|
|
113
|
+
dispensing: { single: number; multi: number };
|
|
114
|
+
forms: { single: number; multi: number };
|
|
115
|
+
compounding: { single: number; multi: number };
|
|
116
|
+
conditional: { single: number; multi: number };
|
|
117
|
+
warning: number;
|
|
118
|
+
units: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type BuildStats = {
|
|
122
|
+
topLevelPatterns: number;
|
|
123
|
+
vocab: VocabStats;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type BuildResult = {
|
|
127
|
+
grammar: Grammar;
|
|
128
|
+
stats: BuildStats;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// grammar assembly
|
|
132
|
+
export function buildGrammar(): BuildResult {
|
|
133
|
+
// Dose must come before bare number, else "50" matches first and leaves "mg" to fall to the word fallback.
|
|
134
|
+
const doseMatch: Pattern = {
|
|
135
|
+
match: `(\\d+(?:[.,]\\d+)?)\\s*(${alt(UNITS)})(?![A-Za-zÀ-ÿ])`,
|
|
136
|
+
captures: {
|
|
137
|
+
"1": { name: SCOPE.number },
|
|
138
|
+
"2": { name: SCOPE.unit },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const bareNumber: Pattern = {
|
|
143
|
+
match: "\\d+(?:[.,]\\d+)?",
|
|
144
|
+
name: SCOPE.number,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const compactFrequency: Pattern = {
|
|
148
|
+
match: "[1-9]\\s*dd(?![A-Za-zÀ-ÿ0-9])",
|
|
149
|
+
name: SCOPE.frequency,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// `ad` is a word too; only paint as fill-marker when followed by digit.
|
|
153
|
+
const fillTo: Pattern = {
|
|
154
|
+
match: "\\bad\\b(?=\\s+\\d)",
|
|
155
|
+
name: SCOPE.fillMarker,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const dtdDirective: Pattern = {
|
|
159
|
+
match: "(?i)(?<![\\w.])(d\\.?t\\.?d\\.?)(?:\\s+(no))?(?=\\s+\\d)",
|
|
160
|
+
captures: {
|
|
161
|
+
"1": { name: SCOPE.dtdKeyword },
|
|
162
|
+
"2": { name: SCOPE.dtdKeyword },
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Case-sensitive — CITO/cito/Cito are separate vocab entries. Painted red.
|
|
167
|
+
const warningAbbrev: Pattern = {
|
|
168
|
+
match: wb(alt(WARNING)),
|
|
169
|
+
name: SCOPE.warning,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Multiword first (longer match wins), then dotted singles. All word-bounded.
|
|
173
|
+
const latinAbbrevs: Pattern[] = [
|
|
174
|
+
{ match: wb(altMultiword(TIMING_MULTIWORD)), name: SCOPE.timing },
|
|
175
|
+
{ match: wb(altMultiword(ROUTE_MULTIWORD)), name: SCOPE.route },
|
|
176
|
+
{ match: wb(altMultiword(DISPENSING_MULTIWORD)), name: SCOPE.dispensing },
|
|
177
|
+
{ match: wb(altMultiword(FORMS_MULTIWORD)), name: SCOPE.form },
|
|
178
|
+
{ match: wb(altMultiword(COMPOUNDING_MULTIWORD)), name: SCOPE.compounding },
|
|
179
|
+
{ match: wb(altMultiword(CONDITIONAL_MULTIWORD)), name: SCOPE.conditional },
|
|
180
|
+
{ match: wb(alt(FREQUENCY)), name: SCOPE.frequency },
|
|
181
|
+
{ match: wb(alt(TIMING)), name: SCOPE.timing },
|
|
182
|
+
{ match: wb(alt(ROUTE)), name: SCOPE.route },
|
|
183
|
+
{ match: wb(alt(DISPENSING)), name: SCOPE.dispensing },
|
|
184
|
+
{ match: wb(alt(FORMS)), name: SCOPE.form },
|
|
185
|
+
{ match: wb(alt(COMPOUNDING)), name: SCOPE.compounding },
|
|
186
|
+
{ match: wb(alt(CONDITIONAL)), name: SCOPE.conditional },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const punctuation: Pattern = {
|
|
190
|
+
match: "[-.,;:()]",
|
|
191
|
+
name: SCOPE.punctuation,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Doc variants must match before their plain counterparts (#! before #, /** before /*).
|
|
195
|
+
const comments: Pattern[] = [
|
|
196
|
+
{ name: SCOPE.docCommentBlock, begin: "/\\*\\*", end: "\\*/" },
|
|
197
|
+
{ name: SCOPE.blockComment, begin: "/\\*", end: "\\*/" },
|
|
198
|
+
{ name: SCOPE.docCommentLine, match: "#!.*$" },
|
|
199
|
+
{ name: SCOPE.lineComment, match: "#.*$" },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
// Dutch patient-prose frequency, built from the tree-sitter-recipe
|
|
203
|
+
// `grammar/dutch` vocab. The whole phrase paints as frequency, mirroring
|
|
204
|
+
// the upstream highlights — `(frequency (number) @keyword.repeat)`,
|
|
205
|
+
// `(count_word) @keyword.repeat`, `(period) @keyword.repeat` — so the
|
|
206
|
+
// leading count (digit or spelled) is part of the frequency, not a dose.
|
|
207
|
+
const period = alt(PERIODS);
|
|
208
|
+
const periodNoun = alt([...PERIOD_PLURALS, ...PERIODS]);
|
|
209
|
+
const dutchFrequency: Pattern[] = [
|
|
210
|
+
// interval: "om de [andere] [N] uur|dag|dagen|…"
|
|
211
|
+
{
|
|
212
|
+
match: `(?i)\\bom[ \\t]+de(?:[ \\t]+andere)?(?:[ \\t]+\\d+)?[ \\t]+(?:${periodNoun})\\b`,
|
|
213
|
+
name: SCOPE.frequency,
|
|
214
|
+
},
|
|
215
|
+
// digit cadence: "3 keer per dag", "3x daags", "2 maal per week"
|
|
216
|
+
{
|
|
217
|
+
match: `(?i)\\b\\d+[ \\t]*(?:${alt(COUNTERS)})[ \\t]+(?:per[ \\t]+(?:${period})|daags)\\b`,
|
|
218
|
+
name: SCOPE.frequency,
|
|
219
|
+
},
|
|
220
|
+
// spelled count word: "driemaal", "eenmaal per dag", "driemaal daags"
|
|
221
|
+
{
|
|
222
|
+
match: `(?i)\\b(?:${alt(NUMBER_WORDS)})[ \\t]*maal(?:[ \\t]+(?:daags|per[ \\t]+(?:${period})))?\\b`,
|
|
223
|
+
name: SCOPE.frequency,
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
// Shared atoms inside every section. Order = first-match priority.
|
|
228
|
+
const sharedAtoms: Pattern[] = [
|
|
229
|
+
...comments,
|
|
230
|
+
warningAbbrev,
|
|
231
|
+
dtdDirective,
|
|
232
|
+
fillTo,
|
|
233
|
+
compactFrequency,
|
|
234
|
+
...dutchFrequency,
|
|
235
|
+
doseMatch,
|
|
236
|
+
...latinAbbrevs,
|
|
237
|
+
bareNumber,
|
|
238
|
+
punctuation,
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sections end only at the literal next marker (R/, Da/, D/, S/) or EOF.
|
|
243
|
+
* The trailing slash is load-bearing: without it, `s\b` inside `s.o.s.`
|
|
244
|
+
* would spuriously close a signa section because `.` is non-word.
|
|
245
|
+
*/
|
|
246
|
+
const nextSection = "(?i)(?=R/|Da?/|S/)|\\z";
|
|
247
|
+
|
|
248
|
+
const makeSection = (
|
|
249
|
+
begin: string,
|
|
250
|
+
marker: string,
|
|
251
|
+
wordScope: string,
|
|
252
|
+
): Pattern => ({
|
|
253
|
+
name: `meta.section.${wordScope.split(".")[2] ?? "unknown"}.recipe`,
|
|
254
|
+
begin,
|
|
255
|
+
beginCaptures: { "0": { name: marker } },
|
|
256
|
+
end: nextSection,
|
|
257
|
+
patterns: [
|
|
258
|
+
...sharedAtoms,
|
|
259
|
+
{ match: "[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ0-9\\-]*", name: wordScope },
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const rxSection = makeSection("(?i)R/", SCOPE.rxMarker, SCOPE.ingredientWord);
|
|
264
|
+
const dispenseSection = makeSection(
|
|
265
|
+
"(?i)Da?/",
|
|
266
|
+
SCOPE.dispenseMarker,
|
|
267
|
+
SCOPE.dispenseWord,
|
|
268
|
+
);
|
|
269
|
+
const signaSection = makeSection(
|
|
270
|
+
"(?i)S/",
|
|
271
|
+
SCOPE.signaMarker,
|
|
272
|
+
SCOPE.signaWord,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const grammar: Grammar = {
|
|
276
|
+
$schema: "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
|
277
|
+
name: "Recipe",
|
|
278
|
+
scopeName: "source.recipe",
|
|
279
|
+
fileTypes: ["recipe"],
|
|
280
|
+
patterns: [
|
|
281
|
+
...comments,
|
|
282
|
+
rxSection,
|
|
283
|
+
dispenseSection,
|
|
284
|
+
signaSection,
|
|
285
|
+
warningAbbrev,
|
|
286
|
+
],
|
|
287
|
+
repository: {
|
|
288
|
+
comments: { patterns: comments },
|
|
289
|
+
"shared-atoms": { patterns: sharedAtoms },
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const stats: BuildStats = {
|
|
294
|
+
topLevelPatterns: countPatterns(grammar.patterns),
|
|
295
|
+
vocab: {
|
|
296
|
+
frequency: FREQUENCY.length,
|
|
297
|
+
timing: { single: TIMING.length, multi: TIMING_MULTIWORD.length },
|
|
298
|
+
route: { single: ROUTE.length, multi: ROUTE_MULTIWORD.length },
|
|
299
|
+
dispensing: {
|
|
300
|
+
single: DISPENSING.length,
|
|
301
|
+
multi: DISPENSING_MULTIWORD.length,
|
|
302
|
+
},
|
|
303
|
+
forms: { single: FORMS.length, multi: FORMS_MULTIWORD.length },
|
|
304
|
+
compounding: {
|
|
305
|
+
single: COMPOUNDING.length,
|
|
306
|
+
multi: COMPOUNDING_MULTIWORD.length,
|
|
307
|
+
},
|
|
308
|
+
conditional: {
|
|
309
|
+
single: CONDITIONAL.length,
|
|
310
|
+
multi: CONDITIONAL_MULTIWORD.length,
|
|
311
|
+
},
|
|
312
|
+
warning: WARNING.length,
|
|
313
|
+
units: UNITS.length,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return { grammar, stats };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function countPatterns(patterns: Pattern[]): number {
|
|
321
|
+
let n = 0;
|
|
322
|
+
for (const p of patterns) {
|
|
323
|
+
n += 1;
|
|
324
|
+
if ("patterns" in p && p.patterns) n += countPatterns(p.patterns);
|
|
325
|
+
}
|
|
326
|
+
return n;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function serializeGrammar(g: Grammar, indent: "tab" | number): string {
|
|
330
|
+
const space = indent === "tab" ? "\t" : indent;
|
|
331
|
+
return `${JSON.stringify(g, null, space)}\n`;
|
|
332
|
+
}
|
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Pure verifier — tokenizes tree-sitter-recipe's own highlight fixtures
|
|
3
|
+
* with the generated TextMate grammar and reports whether each caret assertion
|
|
4
|
+
* lands on a matching scope.
|
|
5
|
+
*
|
|
6
|
+
* No CLI concerns here; the caller supplies paths and decides how to present
|
|
7
|
+
* the result (text table / JSON / exit code).
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
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;
|
|
19
|
+
|
|
20
|
+
// ── capture → scope mapping (inverse of grammar.ts SCOPE) ───────────────────
|
|
21
|
+
// Fixtures speak tree-sitter capture names; the tokenizer speaks TextMate
|
|
22
|
+
// scopes. A token passes when one of its scopes starts with the expected
|
|
23
|
+
// prefix below — the scope tree is hierarchical, so prefix-match is correct.
|
|
24
|
+
const CAPTURE_EXPECTS: Record<string, string> = {
|
|
25
|
+
"keyword.directive": "keyword.control.directive",
|
|
26
|
+
"keyword.repeat": "keyword.other.frequency",
|
|
27
|
+
"keyword.error": "invalid.illegal.warning",
|
|
28
|
+
"keyword.operator": "keyword.operator",
|
|
29
|
+
"keyword.conditional": "keyword.control.conditional",
|
|
30
|
+
"keyword": "keyword.other.timing",
|
|
31
|
+
"function.macro": "support.function.route",
|
|
32
|
+
"attribute": "entity.other.attribute-name",
|
|
33
|
+
"type": "storage.type.form",
|
|
34
|
+
"type.builtin": "support.type.unit",
|
|
35
|
+
"number": "constant.numeric",
|
|
36
|
+
"variable": "variable.other.ingredient",
|
|
37
|
+
"string": "string.unquoted.signa",
|
|
38
|
+
"comment": "comment",
|
|
39
|
+
"comment.documentation": "comment",
|
|
40
|
+
"punctuation.delimiter": "punctuation.separator",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Failure = {
|
|
44
|
+
fixture: string;
|
|
45
|
+
line: number;
|
|
46
|
+
col: number;
|
|
47
|
+
capture: string;
|
|
48
|
+
got: string[] | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type VerifyResult = {
|
|
52
|
+
pass: number;
|
|
53
|
+
total: number;
|
|
54
|
+
failures: Failure[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type VerifyOptions = {
|
|
58
|
+
grammarPath: string;
|
|
59
|
+
fixturesDir: string;
|
|
60
|
+
onigWasmPath: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── fixture parser ──────────────────────────────────────────────────────────
|
|
64
|
+
type Assertion = {
|
|
65
|
+
fixture: string;
|
|
66
|
+
targetLine: number; // 1-indexed source line
|
|
67
|
+
col: number; // 0-indexed column
|
|
68
|
+
capture: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const ASSERT_RE = /^\s*#\s*(<-|\^+)\s+([\w.]+)\s*$/;
|
|
72
|
+
const COMMENT_ONLY_RE = /^\s*#/;
|
|
73
|
+
|
|
74
|
+
function parseFixture(content: string, name: string): { source: string; asserts: Assertion[] } {
|
|
75
|
+
const rawLines = content.split(/\r?\n/);
|
|
76
|
+
const sourceLines: string[] = [];
|
|
77
|
+
const asserts: Assertion[] = [];
|
|
78
|
+
const sourceLineIndexForRawLine: number[] = [];
|
|
79
|
+
|
|
80
|
+
for (const raw of rawLines) {
|
|
81
|
+
if (!COMMENT_ONLY_RE.test(raw)) {
|
|
82
|
+
sourceLines.push(raw);
|
|
83
|
+
sourceLineIndexForRawLine.push(sourceLines.length);
|
|
84
|
+
} else {
|
|
85
|
+
sourceLineIndexForRawLine.push(sourceLines.length);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
90
|
+
const raw = rawLines[i] ?? "";
|
|
91
|
+
if (!COMMENT_ONLY_RE.test(raw)) continue;
|
|
92
|
+
const match = raw.match(ASSERT_RE);
|
|
93
|
+
if (!match) continue;
|
|
94
|
+
const [, kind, capture] = match;
|
|
95
|
+
if (!kind || !capture) continue;
|
|
96
|
+
const targetLine = sourceLineIndexForRawLine[i] ?? 0;
|
|
97
|
+
if (targetLine === 0) continue;
|
|
98
|
+
|
|
99
|
+
const col = kind === "<-" ? 0 : raw.indexOf("^");
|
|
100
|
+
asserts.push({ fixture: name, targetLine, col, capture });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { source: sourceLines.join("\n"), asserts };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── main ────────────────────────────────────────────────────────────────────
|
|
107
|
+
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
|
+
});
|
|
115
|
+
|
|
116
|
+
const rawGrammar = parseRawGrammar(
|
|
117
|
+
readFileSync(opts.grammarPath, "utf-8"),
|
|
118
|
+
opts.grammarPath,
|
|
119
|
+
);
|
|
120
|
+
const registry = new Registry({ onigLib, loadGrammar: async () => null });
|
|
121
|
+
const grammar = await registry.addGrammar(rawGrammar);
|
|
122
|
+
|
|
123
|
+
const result: VerifyResult = { pass: 0, total: 0, failures: [] };
|
|
124
|
+
|
|
125
|
+
for (const name of readdirSync(opts.fixturesDir).sort()) {
|
|
126
|
+
if (!name.endsWith(".recipe")) continue;
|
|
127
|
+
const content = readFileSync(resolve(opts.fixturesDir, name), "utf-8");
|
|
128
|
+
const { source, asserts } = parseFixture(content, name);
|
|
129
|
+
|
|
130
|
+
const sourceLines = source.split("\n");
|
|
131
|
+
let ruleStack: StateStack | null = null;
|
|
132
|
+
const perLine: { start: number; end: number; scopes: string[] }[][] = [];
|
|
133
|
+
for (const line of sourceLines) {
|
|
134
|
+
const r = grammar.tokenizeLine(line, ruleStack);
|
|
135
|
+
perLine.push(r.tokens.map((t) => ({
|
|
136
|
+
start: t.startIndex,
|
|
137
|
+
end: t.endIndex,
|
|
138
|
+
scopes: [...t.scopes],
|
|
139
|
+
})));
|
|
140
|
+
ruleStack = r.ruleStack;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const a of asserts) {
|
|
144
|
+
result.total += 1;
|
|
145
|
+
const tokens = perLine[a.targetLine - 1];
|
|
146
|
+
// A caret may sit one past the final character (a token's exclusive
|
|
147
|
+
// end at end-of-line) — tree-sitter's own harness accepts that, so
|
|
148
|
+
// fall back to the token whose right boundary equals the column.
|
|
149
|
+
const hit = tokens?.find((t) => a.col >= t.start && a.col < t.end)
|
|
150
|
+
?? tokens?.find((t) => a.col === t.end);
|
|
151
|
+
const expected = CAPTURE_EXPECTS[a.capture];
|
|
152
|
+
const passed = !!(hit && expected && hit.scopes.some((s) => s.startsWith(expected)));
|
|
153
|
+
if (passed) {
|
|
154
|
+
result.pass += 1;
|
|
155
|
+
} else {
|
|
156
|
+
result.failures.push({
|
|
157
|
+
fixture: a.fixture,
|
|
158
|
+
line: a.targetLine,
|
|
159
|
+
col: a.col,
|
|
160
|
+
capture: a.capture,
|
|
161
|
+
got: hit ? hit.scopes : null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return result;
|
|
168
|
+
}
|