@reteps/tree-sitter-htmlmustache 0.8.0 → 0.9.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.
Files changed (67) hide show
  1. package/README.md +49 -33
  2. package/browser/out/browser/index.d.ts +43 -0
  3. package/browser/out/browser/index.d.ts.map +1 -0
  4. package/browser/out/browser/index.mjs +3612 -0
  5. package/browser/out/browser/index.mjs.map +7 -0
  6. package/browser/out/core/collectErrors.d.ts +36 -0
  7. package/browser/out/core/collectErrors.d.ts.map +1 -0
  8. package/browser/out/core/configSchema.d.ts +63 -0
  9. package/browser/out/core/configSchema.d.ts.map +1 -0
  10. package/browser/out/core/customCodeTags.d.ts +34 -0
  11. package/browser/out/core/customCodeTags.d.ts.map +1 -0
  12. package/browser/out/core/diagnostic.d.ts +24 -0
  13. package/browser/out/core/diagnostic.d.ts.map +1 -0
  14. package/browser/out/core/embeddedRegions.d.ts +12 -0
  15. package/browser/out/core/embeddedRegions.d.ts.map +1 -0
  16. package/browser/out/core/formatting/classifier.d.ts +68 -0
  17. package/browser/out/core/formatting/classifier.d.ts.map +1 -0
  18. package/browser/out/core/formatting/embedded.d.ts +19 -0
  19. package/browser/out/core/formatting/embedded.d.ts.map +1 -0
  20. package/browser/out/core/formatting/formatters.d.ts +85 -0
  21. package/browser/out/core/formatting/formatters.d.ts.map +1 -0
  22. package/browser/out/core/formatting/index.d.ts +44 -0
  23. package/browser/out/core/formatting/index.d.ts.map +1 -0
  24. package/browser/out/core/formatting/ir.d.ts +100 -0
  25. package/browser/out/core/formatting/ir.d.ts.map +1 -0
  26. package/browser/out/core/formatting/mergeOptions.d.ts +18 -0
  27. package/browser/out/core/formatting/mergeOptions.d.ts.map +1 -0
  28. package/browser/out/core/formatting/printer.d.ts +18 -0
  29. package/browser/out/core/formatting/printer.d.ts.map +1 -0
  30. package/browser/out/core/formatting/utils.d.ts +39 -0
  31. package/browser/out/core/formatting/utils.d.ts.map +1 -0
  32. package/browser/out/core/grammar.d.ts +3 -0
  33. package/browser/out/core/grammar.d.ts.map +1 -0
  34. package/browser/out/core/htmlBalanceChecker.d.ts +23 -0
  35. package/browser/out/core/htmlBalanceChecker.d.ts.map +1 -0
  36. package/browser/out/core/mustacheChecks.d.ts +24 -0
  37. package/browser/out/core/mustacheChecks.d.ts.map +1 -0
  38. package/browser/out/core/nodeHelpers.d.ts +54 -0
  39. package/browser/out/core/nodeHelpers.d.ts.map +1 -0
  40. package/browser/out/core/ruleMetadata.d.ts +12 -0
  41. package/browser/out/core/ruleMetadata.d.ts.map +1 -0
  42. package/browser/out/core/selectorMatcher.d.ts +74 -0
  43. package/browser/out/core/selectorMatcher.d.ts.map +1 -0
  44. package/cli/out/main.js +168 -122
  45. package/package.json +21 -3
  46. package/src/browser/browser.test.ts +207 -0
  47. package/src/browser/index.ts +128 -0
  48. package/src/browser/tsconfig.json +18 -0
  49. package/src/core/collectErrors.ts +233 -0
  50. package/src/core/configSchema.ts +273 -0
  51. package/src/core/customCodeTags.ts +159 -0
  52. package/src/core/diagnostic.ts +45 -0
  53. package/src/core/embeddedRegions.ts +70 -0
  54. package/src/core/formatting/classifier.ts +549 -0
  55. package/src/core/formatting/embedded.ts +56 -0
  56. package/src/core/formatting/formatters.ts +1272 -0
  57. package/src/core/formatting/index.ts +185 -0
  58. package/src/core/formatting/ir.ts +202 -0
  59. package/src/core/formatting/mergeOptions.ts +34 -0
  60. package/src/core/formatting/printer.ts +242 -0
  61. package/src/core/formatting/utils.ts +193 -0
  62. package/src/core/grammar.ts +2 -0
  63. package/src/core/htmlBalanceChecker.ts +382 -0
  64. package/src/core/mustacheChecks.ts +504 -0
  65. package/src/core/nodeHelpers.ts +126 -0
  66. package/src/core/ruleMetadata.ts +63 -0
  67. package/src/core/selectorMatcher.ts +719 -0
@@ -0,0 +1,719 @@
1
+ /**
2
+ * CSS-like selector parser and tree matcher for custom lint rules.
3
+ *
4
+ * Mustache constructs are written literally in selectors — `{{foo}}`,
5
+ * `{{{foo}}}`, `{{#foo}}`, `{{^foo}}`, `{{!foo}}`, `{{>foo}}`. A preprocessor
6
+ * substitutes each form into an internal `:m-*` pseudo-class marker, then the
7
+ * rewritten string is handed to parsel-js. This keeps users in Mustache
8
+ * vocabulary without reinventing a selector parser.
9
+ *
10
+ * Supported user-facing syntax:
11
+ * - Tag names (`div`), universal (`*`), classes (`.foo`), ids (`#foo`)
12
+ * - Attributes: `[attr]`, `[attr=v]`, `[attr^=v]`, `[attr*=v]`, `[attr$=v]`, `[attr~=v]`
13
+ * - Descendant (space) and child (`>`) combinators
14
+ * - Mustache variables: `{{path}}` and `{{{path}}}` (raw)
15
+ * - Mustache sections: `{{#name}}` and `{{^name}}` (inverted)
16
+ * - Mustache comments: `{{!content}}`
17
+ * - Mustache partials: `{{>name}}`
18
+ * - Glob wildcard `*` inside the argument: `{{options.*}}`, `{{*.deprecated}}`, `{{*}}`
19
+ * - `:has(selector)` — element has a matching descendant
20
+ * - `:not(...)` over any attribute/class/id/:has form
21
+ * - `:root` — the tree-sitter fragment root (the whole document). Unlike
22
+ * browser CSS where `:root` matches `<html>`, this matches the parse-tree
23
+ * root so it works on partials/fragments too. Useful as a document-scoped
24
+ * anchor, e.g. `:root:has(pl-question-panel):not(:has(pl-answer-panel))`
25
+ * matches the root iff a `pl-question-panel` is present anywhere but no
26
+ * `pl-answer-panel` is. Cannot combine with tag/class/id/attribute in the
27
+ * same compound (only with `:has` / `:not(:has(...))`). Inside `:has(...)`,
28
+ * `:root` refers to the element being checked, not the document.
29
+ * - Comma-separated alternatives
30
+ *
31
+ * Unsupported (parseSelector returns null, rule is skipped):
32
+ * - Sibling combinators (`+`, `~`)
33
+ * - `[attr|=v]`, case-insensitive `i` flag
34
+ * - Mixed HTML + Mustache kinds in one compound (e.g. `img{{foo}}`)
35
+ * - `{{/end}}` (end tags aren't standalone nodes)
36
+ * - `{{=<% %>=}}` (delimiter changes aren't grammar-tracked)
37
+ * - Mustache literals inside `:not(...)` (only attribute/class/id/:has)
38
+ */
39
+
40
+ import { parse as parselParse, type AST, type Token, type AttributeToken, type ClassToken, type IdToken } from 'parsel-js';
41
+ import type { BalanceNode } from './htmlBalanceChecker.js';
42
+ import {
43
+ getTagName,
44
+ getSectionName,
45
+ getInterpolationPath,
46
+ getCommentContent,
47
+ getPartialName,
48
+ HTML_ELEMENT_TYPES,
49
+ } from './nodeHelpers.js';
50
+
51
+ // --- Types ---
52
+
53
+ export type AttributeOperator = '=' | '^=' | '*=' | '$=' | '~=';
54
+
55
+ export type SegmentKind =
56
+ | 'html'
57
+ | 'section'
58
+ | 'inverted'
59
+ | 'variable'
60
+ | 'raw'
61
+ | 'comment'
62
+ | 'partial';
63
+
64
+ export interface AttributeConstraint {
65
+ name: string; // lowercased
66
+ op: AttributeOperator; // meaningful only when value !== undefined
67
+ value?: string; // quotes stripped; undefined => presence check
68
+ negated: boolean; // true when inside :not()
69
+ }
70
+
71
+ export interface DescendantCheck {
72
+ selector: ParsedSelector; // :has(selector) — must match a descendant
73
+ negated: boolean; // true for :not(:has(...))
74
+ }
75
+
76
+ export interface Segment {
77
+ kind: SegmentKind;
78
+ rootOnly: boolean; // true for `:root` — matches only the tree-sitter fragment root
79
+ name: string | null; // lowercased identifier/path, null = wildcard
80
+ pathRegex?: RegExp; // compiled glob when `name` contains `*`
81
+ attributes: AttributeConstraint[];
82
+ descendantChecks: DescendantCheck[];
83
+ combinator: 'descendant' | 'child';
84
+ }
85
+
86
+ /** A parsed selector is a list of alternatives (from comma-separated parts). */
87
+ export type ParsedSelector = Segment[][];
88
+
89
+ // --- Mustache preprocessor ---
90
+
91
+ const MUSTACHE_KIND_PSEUDO = new Set([
92
+ 'm-section', 'm-inverted', 'm-variable', 'm-raw', 'm-comment', 'm-partial',
93
+ ]);
94
+
95
+ /**
96
+ * Rewrite Mustache-literal tokens (`{{...}}` forms) in the selector string into
97
+ * internal `:m-*` pseudo-class markers so parsel-js can handle them. Returns
98
+ * null if the string contains an unsupported or malformed Mustache token.
99
+ *
100
+ * Skips content inside `"..."` and `'...'` so that literal `{{...}}` embedded
101
+ * in CSS attribute-value strings is preserved unchanged.
102
+ */
103
+ export function preprocessMustacheLiterals(raw: string): string | null {
104
+ let out = '';
105
+ let i = 0;
106
+ const len = raw.length;
107
+
108
+ while (i < len) {
109
+ const ch = raw[i];
110
+
111
+ // Pass through quoted strings verbatim.
112
+ if (ch === '"' || ch === "'") {
113
+ out += ch;
114
+ i++;
115
+ while (i < len && raw[i] !== ch) {
116
+ if (raw[i] === '\\' && i + 1 < len) {
117
+ out += raw[i] + raw[i + 1];
118
+ i += 2;
119
+ } else {
120
+ out += raw[i];
121
+ i++;
122
+ }
123
+ }
124
+ if (i < len) {
125
+ out += raw[i]; // closing quote
126
+ i++;
127
+ }
128
+ continue;
129
+ }
130
+
131
+ if (ch !== '{' || raw[i + 1] !== '{') {
132
+ out += ch;
133
+ i++;
134
+ continue;
135
+ }
136
+
137
+ // Triple-brace: {{{path}}}
138
+ if (raw[i + 2] === '{') {
139
+ const end = raw.indexOf('}}}', i + 3);
140
+ if (end < 0) return null;
141
+ const inner = raw.slice(i + 3, end).trim();
142
+ if (inner.length === 0) return null;
143
+ out += `:m-raw(${inner})`;
144
+ i = end + 3;
145
+ continue;
146
+ }
147
+
148
+ // Double-brace: {{...}}
149
+ const end = raw.indexOf('}}', i + 2);
150
+ if (end < 0) return null;
151
+ const body = raw.slice(i + 2, end);
152
+ i = end + 2;
153
+
154
+ const sigil = body.trimStart()[0];
155
+ const content = body.replace(/^\s*[#^!>/]\s*/, '').replace(/^\s+|\s+$/g, '');
156
+
157
+ switch (sigil) {
158
+ case '#':
159
+ if (content.length === 0) return null;
160
+ out += `:m-section(${content})`;
161
+ break;
162
+ case '^':
163
+ if (content.length === 0) return null;
164
+ out += `:m-inverted(${content})`;
165
+ break;
166
+ case '!':
167
+ if (content.length === 0) return null;
168
+ out += `:m-comment(${content})`;
169
+ break;
170
+ case '>':
171
+ if (content.length === 0) return null;
172
+ out += `:m-partial(${content})`;
173
+ break;
174
+ case '/':
175
+ // Standalone end tags are not a selectable node.
176
+ return null;
177
+ case '=':
178
+ // Delimiter changes are not a grammar-tracked node.
179
+ return null;
180
+ default: {
181
+ const path = body.trim();
182
+ if (path.length === 0) return null;
183
+ out += `:m-variable(${path})`;
184
+ break;
185
+ }
186
+ }
187
+ }
188
+
189
+ return out;
190
+ }
191
+
192
+ // --- Selector parsing ---
193
+
194
+ export function parseSelector(raw: string): ParsedSelector | null {
195
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
196
+
197
+ const preprocessed = preprocessMustacheLiterals(raw);
198
+ if (preprocessed === null) return null;
199
+
200
+ let ast: AST | undefined;
201
+ try {
202
+ ast = parselParse(preprocessed);
203
+ } catch {
204
+ return null;
205
+ }
206
+ if (!ast) return null;
207
+
208
+ const tops = ast.type === 'list' ? ast.list : [ast];
209
+ const alts: Segment[][] = [];
210
+ for (const top of tops) {
211
+ const segments: Segment[] = [];
212
+ if (!collectSegments(top, 'descendant', segments)) return null;
213
+ if (segments.length === 0) return null;
214
+ alts.push(segments);
215
+ }
216
+ return alts.length > 0 ? alts : null;
217
+ }
218
+
219
+ function collectSegments(
220
+ ast: AST,
221
+ combinator: 'descendant' | 'child',
222
+ out: Segment[],
223
+ ): boolean {
224
+ if (ast.type === 'complex') {
225
+ const mapped = mapCombinator(ast.combinator);
226
+ if (!mapped) return false;
227
+ return collectSegments(ast.left, 'descendant', out)
228
+ && collectSegments(ast.right, mapped, out);
229
+ }
230
+ if (ast.type === 'list' || ast.type === 'relative') return false;
231
+
232
+ const segment = segmentFromCompound(ast);
233
+ if (!segment) return false;
234
+ segment.combinator = combinator;
235
+ out.push(segment);
236
+ return true;
237
+ }
238
+
239
+ function mapCombinator(c: string): 'descendant' | 'child' | null {
240
+ const trimmed = c.trim();
241
+ if (trimmed === '') return 'descendant';
242
+ if (trimmed === '>') return 'child';
243
+ return null;
244
+ }
245
+
246
+ function segmentFromCompound(ast: AST): Segment | null {
247
+ const tokens: Token[] = ast.type === 'compound' ? ast.list : [ast as Token];
248
+
249
+ let kind: SegmentKind | undefined;
250
+ let name: string | null = null;
251
+ let pathRegex: RegExp | undefined;
252
+ let rootOnly = false;
253
+ const attributes: AttributeConstraint[] = [];
254
+ const descendantChecks: DescendantCheck[] = [];
255
+
256
+ // Once a Mustache kind is picked, no other kind tokens may appear.
257
+ const forbidChange = (requested: SegmentKind): boolean => {
258
+ if (kind === undefined) return false;
259
+ if (kind === requested) return false;
260
+ // html and Mustache kinds never mix
261
+ return true;
262
+ };
263
+
264
+ for (const token of tokens) {
265
+ switch (token.type) {
266
+ case 'type':
267
+ if (forbidChange('html')) return null;
268
+ kind = 'html';
269
+ name = token.name.toLowerCase();
270
+ break;
271
+ case 'universal':
272
+ if (forbidChange('html')) return null;
273
+ kind = 'html';
274
+ name = null;
275
+ break;
276
+ case 'class':
277
+ if (forbidChange('html')) return null;
278
+ kind = 'html';
279
+ attributes.push(classConstraint(token, false));
280
+ break;
281
+ case 'id':
282
+ if (forbidChange('html')) return null;
283
+ kind = 'html';
284
+ attributes.push(idConstraint(token, false));
285
+ break;
286
+ case 'attribute': {
287
+ // Attribute selectors only apply to HTML segments.
288
+ if (forbidChange('html')) return null;
289
+ if (kind === undefined) kind = 'html';
290
+ const c = attributeConstraint(token, false);
291
+ if (!c) return null;
292
+ attributes.push(c);
293
+ break;
294
+ }
295
+ case 'pseudo-class': {
296
+ if (MUSTACHE_KIND_PSEUDO.has(token.name)) {
297
+ const mustacheKind = mustacheKindFromMarker(token.name);
298
+ if (mustacheKind === null) return null;
299
+ if (forbidChange(mustacheKind)) return null;
300
+ kind = mustacheKind;
301
+ const glob = parseGlob(token.argument ?? '');
302
+ name = glob.name;
303
+ pathRegex = glob.pathRegex;
304
+ break;
305
+ }
306
+ if (token.name === 'has') {
307
+ const sel = subtreeToSelector(token.subtree);
308
+ if (!sel) return null;
309
+ descendantChecks.push({ selector: sel, negated: false });
310
+ break;
311
+ }
312
+ if (token.name === 'not') {
313
+ if (!applyNegatedSubtree(token.subtree, attributes, descendantChecks)) return null;
314
+ break;
315
+ }
316
+ if (token.name === 'root') {
317
+ rootOnly = true;
318
+ if (kind === undefined) kind = 'html';
319
+ break;
320
+ }
321
+ return null;
322
+ }
323
+ default:
324
+ // pseudo-element, comma, combinator, unknown → unsupported
325
+ return null;
326
+ }
327
+ }
328
+
329
+ if (kind === undefined) {
330
+ kind = 'html';
331
+ }
332
+
333
+ if (rootOnly) {
334
+ // `:root` is only meaningful on its own (optionally with :has / :not(:has)).
335
+ // Reject tag/attribute/class/id combinations — the root isn't an HTML element.
336
+ if (name !== null || attributes.length > 0 || kind !== 'html') return null;
337
+ }
338
+
339
+ const isHtml = kind === 'html';
340
+ const finalAttrs = isHtml ? attributes : [];
341
+ return { kind, rootOnly, name, pathRegex, attributes: finalAttrs, descendantChecks, combinator: 'descendant' };
342
+ }
343
+
344
+ function mustacheKindFromMarker(name: string): SegmentKind | null {
345
+ switch (name) {
346
+ case 'm-section': return 'section';
347
+ case 'm-inverted': return 'inverted';
348
+ case 'm-variable': return 'variable';
349
+ case 'm-raw': return 'raw';
350
+ case 'm-comment': return 'comment';
351
+ case 'm-partial': return 'partial';
352
+ default: return null;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Parse a Mustache-literal argument into an exact name or a compiled glob.
358
+ * The '*' character is the only wildcard. Bare '*' or empty string returns
359
+ * { name: null } — a wildcard that matches any value.
360
+ */
361
+ function parseGlob(arg: string): { name: string | null; pathRegex?: RegExp } {
362
+ const trimmed = arg.trim();
363
+ if (trimmed === '' || trimmed === '*') {
364
+ return { name: null }; // wildcard — matches anything
365
+ }
366
+ if (!trimmed.includes('*')) {
367
+ return { name: trimmed.toLowerCase() };
368
+ }
369
+ // Escape regex metacharacters except `*`, then substitute `*` → `.*`.
370
+ const escaped = trimmed.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
371
+ const pathRegex = new RegExp(`^${escaped}$`, 'i');
372
+ return { name: trimmed.toLowerCase(), pathRegex };
373
+ }
374
+
375
+ function attributeConstraint(token: AttributeToken, negated: boolean): AttributeConstraint | null {
376
+ const name = token.name.toLowerCase();
377
+ if (token.operator === undefined) {
378
+ return { name, op: '=', value: undefined, negated };
379
+ }
380
+ let op: AttributeOperator;
381
+ switch (token.operator) {
382
+ case '=': op = '='; break;
383
+ case '^=': op = '^='; break;
384
+ case '*=': op = '*='; break;
385
+ case '$=': op = '$='; break;
386
+ case '~=': op = '~='; break;
387
+ default: return null;
388
+ }
389
+ return { name, op, value: stripQuotes(token.value ?? ''), negated };
390
+ }
391
+
392
+ function classConstraint(token: ClassToken, negated: boolean): AttributeConstraint {
393
+ return { name: 'class', op: '~=', value: token.name, negated };
394
+ }
395
+
396
+ function idConstraint(token: IdToken, negated: boolean): AttributeConstraint {
397
+ return { name: 'id', op: '=', value: token.name, negated };
398
+ }
399
+
400
+ function applyNegatedSubtree(
401
+ subtree: AST | undefined,
402
+ attributes: AttributeConstraint[],
403
+ descendantChecks: DescendantCheck[],
404
+ ): boolean {
405
+ if (!subtree) return false;
406
+ if (subtree.type === 'attribute') {
407
+ const c = attributeConstraint(subtree, true);
408
+ if (!c) return false;
409
+ attributes.push(c);
410
+ return true;
411
+ }
412
+ if (subtree.type === 'class') {
413
+ attributes.push(classConstraint(subtree, true));
414
+ return true;
415
+ }
416
+ if (subtree.type === 'id') {
417
+ attributes.push(idConstraint(subtree, true));
418
+ return true;
419
+ }
420
+ if (subtree.type === 'pseudo-class' && subtree.name === 'has') {
421
+ const sel = subtreeToSelector(subtree.subtree);
422
+ if (!sel) return false;
423
+ descendantChecks.push({ selector: sel, negated: true });
424
+ return true;
425
+ }
426
+ return false;
427
+ }
428
+
429
+ function subtreeToSelector(subtree: AST | undefined): ParsedSelector | null {
430
+ if (!subtree) return null;
431
+ const tops = subtree.type === 'list' ? subtree.list : [subtree];
432
+ const alts: Segment[][] = [];
433
+ for (const top of tops) {
434
+ const segments: Segment[] = [];
435
+ if (!collectSegments(top, 'descendant', segments)) return null;
436
+ if (segments.length === 0) return null;
437
+ alts.push(segments);
438
+ }
439
+ return alts.length > 0 ? alts : null;
440
+ }
441
+
442
+ function stripQuotes(raw: string): string {
443
+ if (raw.length < 2) return raw;
444
+ const first = raw[0];
445
+ const last = raw[raw.length - 1];
446
+ if ((first === '"' || first === "'") && first === last) {
447
+ return raw.slice(1, -1);
448
+ }
449
+ return raw;
450
+ }
451
+
452
+ // --- Tree matching ---
453
+
454
+ interface AncestorEntry {
455
+ kind: AncestorKind;
456
+ name: string; // lowercased
457
+ node: BalanceNode;
458
+ }
459
+
460
+ type AncestorKind = 'html' | 'section' | 'inverted' | 'root';
461
+
462
+ function ancestorKindForNode(node: BalanceNode): AncestorKind | null {
463
+ if (HTML_ELEMENT_TYPES.has(node.type)) return 'html';
464
+ if (node.type === 'mustache_section') return 'section';
465
+ if (node.type === 'mustache_inverted_section') return 'inverted';
466
+ return null;
467
+ }
468
+
469
+ function getHtmlAttributes(node: BalanceNode): { name: string; value?: string }[] {
470
+ const startTag = node.children.find(
471
+ c => c.type === 'html_start_tag' || c.type === 'html_self_closing_tag',
472
+ );
473
+ if (!startTag) return [];
474
+
475
+ const attrs: { name: string; value?: string }[] = [];
476
+ for (const child of startTag.children) {
477
+ if (child.type !== 'html_attribute') continue;
478
+ let attrName = '';
479
+ let attrValue: string | undefined;
480
+ for (const part of child.children) {
481
+ if (part.type === 'html_attribute_name') {
482
+ attrName = part.text.toLowerCase();
483
+ } else if (part.type === 'html_quoted_attribute_value') {
484
+ attrValue = part.text.replace(/^["']|["']$/g, '');
485
+ } else if (part.type === 'html_attribute_value') {
486
+ attrValue = part.text;
487
+ }
488
+ }
489
+ if (attrName) attrs.push({ name: attrName, value: attrValue });
490
+ }
491
+ return attrs;
492
+ }
493
+
494
+ function matchesAttributeValue(has: string | undefined, c: AttributeConstraint): boolean {
495
+ if (has === undefined || c.value === undefined) return false;
496
+ const v = c.value;
497
+ if (v === '') return false;
498
+ switch (c.op) {
499
+ case '=': return has === v;
500
+ case '^=': return has.startsWith(v);
501
+ case '*=': return has.includes(v);
502
+ case '$=': return has.endsWith(v);
503
+ case '~=': return has.split(/\s+/).includes(v);
504
+ }
505
+ }
506
+
507
+ function checkAttributes(node: BalanceNode, constraints: AttributeConstraint[]): boolean {
508
+ if (constraints.length === 0) return true;
509
+ const nodeAttrs = getHtmlAttributes(node);
510
+ for (const c of constraints) {
511
+ const found = nodeAttrs.find(a => a.name === c.name);
512
+ if (c.negated) {
513
+ if (!found) continue;
514
+ if (c.value === undefined) return false;
515
+ if (matchesAttributeValue(found.value, c)) return false;
516
+ continue;
517
+ }
518
+ if (c.value === undefined) {
519
+ if (!found) return false;
520
+ continue;
521
+ }
522
+ if (!found || !matchesAttributeValue(found.value, c)) return false;
523
+ }
524
+ return true;
525
+ }
526
+
527
+ function checkDescendants(node: BalanceNode, checks: DescendantCheck[]): boolean {
528
+ if (checks.length === 0) return true;
529
+ for (const check of checks) {
530
+ const present = hasDescendantMatch(node, check.selector);
531
+ if (check.negated ? present : !present) return false;
532
+ }
533
+ return true;
534
+ }
535
+
536
+ function hasDescendantMatch(node: BalanceNode, selector: ParsedSelector): boolean {
537
+ for (const child of node.children) {
538
+ if (matchSelector(child, selector).length > 0) return true;
539
+ }
540
+ return false;
541
+ }
542
+
543
+ function matchesName(actual: string | null, segment: Segment): boolean {
544
+ if (segment.name === null) return true; // wildcard
545
+ if (actual === null) return false;
546
+ if (segment.pathRegex) return segment.pathRegex.test(actual);
547
+ return actual === segment.name;
548
+ }
549
+
550
+ function nodeMatchesSegment(node: BalanceNode, segment: Segment, rootNode: BalanceNode): boolean {
551
+ if (segment.rootOnly) {
552
+ if (node !== rootNode) return false;
553
+ return checkDescendants(node, segment.descendantChecks);
554
+ }
555
+ switch (segment.kind) {
556
+ case 'html': {
557
+ if (!HTML_ELEMENT_TYPES.has(node.type)) return false;
558
+ if (segment.name !== null) {
559
+ const tagName = getTagName(node)?.toLowerCase();
560
+ if (tagName !== segment.name) return false;
561
+ }
562
+ return checkAttributes(node, segment.attributes) && checkDescendants(node, segment.descendantChecks);
563
+ }
564
+ case 'section':
565
+ if (node.type !== 'mustache_section') return false;
566
+ if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
567
+ return checkDescendants(node, segment.descendantChecks);
568
+ case 'inverted':
569
+ if (node.type !== 'mustache_inverted_section') return false;
570
+ if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
571
+ return checkDescendants(node, segment.descendantChecks);
572
+ case 'variable':
573
+ if (node.type !== 'mustache_interpolation') return false;
574
+ if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
575
+ return checkDescendants(node, segment.descendantChecks);
576
+ case 'raw':
577
+ if (node.type !== 'mustache_triple') return false;
578
+ if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
579
+ return checkDescendants(node, segment.descendantChecks);
580
+ case 'comment':
581
+ if (node.type !== 'mustache_comment') return false;
582
+ if (!matchesName(getCommentContent(node)?.toLowerCase() ?? null, segment)) return false;
583
+ return checkDescendants(node, segment.descendantChecks);
584
+ case 'partial':
585
+ if (node.type !== 'mustache_partial') return false;
586
+ if (!matchesName(getPartialName(node)?.toLowerCase() ?? null, segment)) return false;
587
+ return checkDescendants(node, segment.descendantChecks);
588
+ }
589
+ }
590
+
591
+ /** Does the ancestor stack satisfy remaining segments? */
592
+ function checkAncestors(
593
+ ancestors: AncestorEntry[],
594
+ segments: Segment[],
595
+ segIdx: number,
596
+ childCombinator: 'descendant' | 'child',
597
+ ): boolean {
598
+ if (segIdx < 0) return true;
599
+ const segment = segments[segIdx];
600
+ const ancestorKind = ancestorKindForSegment(segment);
601
+ if (ancestorKind === null) return false; // variable/raw/comment/partial can't be ancestors
602
+
603
+ if (childCombinator === 'child') {
604
+ for (let a = ancestors.length - 1; a >= 0; a--) {
605
+ const entry = ancestors[a];
606
+ if (entry.kind !== ancestorKind) {
607
+ // For `:root > X` (ancestorKind='root'), any html ancestor between
608
+ // X and the document root breaks the direct-child relationship.
609
+ // Mustache sections are transparent (existing braided semantics).
610
+ if (ancestorKind === 'root' && entry.kind === 'html') return false;
611
+ continue;
612
+ }
613
+ if (!matchesName(entry.name, segment)) return false;
614
+ if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) return false;
615
+ if (!checkDescendants(entry.node, segment.descendantChecks)) return false;
616
+ return checkAncestors(ancestors.slice(0, a), segments, segIdx - 1, segment.combinator);
617
+ }
618
+ return false;
619
+ }
620
+
621
+ for (let a = ancestors.length - 1; a >= 0; a--) {
622
+ const entry = ancestors[a];
623
+ if (entry.kind !== ancestorKind) continue;
624
+ if (!matchesName(entry.name, segment)) continue;
625
+ if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) continue;
626
+ if (!checkDescendants(entry.node, segment.descendantChecks)) continue;
627
+ if (checkAncestors(ancestors.slice(0, a), segments, segIdx - 1, segment.combinator)) {
628
+ return true;
629
+ }
630
+ }
631
+ return false;
632
+ }
633
+
634
+ function ancestorKindForSegment(segment: Segment): AncestorKind | null {
635
+ if (segment.rootOnly) return 'root';
636
+ if (segment.kind === 'html') return 'html';
637
+ if (segment.kind === 'section') return 'section';
638
+ if (segment.kind === 'inverted') return 'inverted';
639
+ return null;
640
+ }
641
+
642
+ function getReportNode(node: BalanceNode, rootNode?: BalanceNode): BalanceNode {
643
+ if (HTML_ELEMENT_TYPES.has(node.type)) {
644
+ const startTag = node.children.find(
645
+ c => c.type === 'html_start_tag' || c.type === 'html_self_closing_tag',
646
+ );
647
+ return startTag ?? node;
648
+ }
649
+ if (node.type === 'mustache_section' || node.type === 'mustache_inverted_section') {
650
+ const begin = node.children.find(
651
+ c => c.type === 'mustache_section_begin' || c.type === 'mustache_inverted_section_begin',
652
+ );
653
+ return begin ?? node;
654
+ }
655
+ // When `:root` matches, the node covers the whole document. Narrow the
656
+ // reported range to a 1-char span at the document start so the diagnostic
657
+ // squiggle isn't the entire file.
658
+ if (rootNode && node === rootNode) {
659
+ return {
660
+ type: node.type,
661
+ text: '',
662
+ startPosition: node.startPosition,
663
+ endPosition: { row: node.startPosition.row, column: node.startPosition.column + 1 },
664
+ startIndex: node.startIndex,
665
+ endIndex: Math.min(node.startIndex + 1, node.endIndex),
666
+ children: [],
667
+ };
668
+ }
669
+ return node;
670
+ }
671
+
672
+ function matchAlternative(rootNode: BalanceNode, segments: Segment[]): BalanceNode[] {
673
+ const results: BalanceNode[] = [];
674
+ const lastSegment = segments[segments.length - 1];
675
+
676
+ function walk(node: BalanceNode, ancestors: AncestorEntry[]) {
677
+ if (nodeMatchesSegment(node, lastSegment, rootNode)) {
678
+ if (
679
+ segments.length === 1 ||
680
+ checkAncestors(ancestors, segments, segments.length - 2, lastSegment.combinator)
681
+ ) {
682
+ results.push(getReportNode(node, rootNode));
683
+ }
684
+ }
685
+
686
+ let newAncestors = ancestors;
687
+ const ancestorKind = ancestorKindForNode(node);
688
+ if (ancestorKind !== null) {
689
+ const name =
690
+ ancestorKind === 'html' ? getTagName(node)?.toLowerCase() :
691
+ getSectionName(node)?.toLowerCase();
692
+ if (name) {
693
+ newAncestors = [...ancestors, { kind: ancestorKind, name, node }];
694
+ }
695
+ }
696
+
697
+ for (const child of node.children) walk(child, newAncestors);
698
+ }
699
+
700
+ // Seed the ancestor stack with a root entry so `:root X` / `:root > X`
701
+ // can find the document root as an ancestor. The root node itself is
702
+ // never an html/section/inverted node, so it's otherwise never pushed.
703
+ walk(rootNode, [{ kind: 'root', name: '', node: rootNode }]);
704
+ return results;
705
+ }
706
+
707
+ export function matchSelector(rootNode: BalanceNode, selector: ParsedSelector): BalanceNode[] {
708
+ const allResults: BalanceNode[] = [];
709
+ const seen = new Set<BalanceNode>();
710
+ for (const alt of selector) {
711
+ for (const node of matchAlternative(rootNode, alt)) {
712
+ if (!seen.has(node)) {
713
+ seen.add(node);
714
+ allResults.push(node);
715
+ }
716
+ }
717
+ }
718
+ return allResults;
719
+ }