@reteps/tree-sitter-htmlmustache 0.8.1 → 0.9.1
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/README.md +1 -1
- package/browser/out/browser/index.d.ts +43 -0
- package/browser/out/browser/index.d.ts.map +1 -0
- package/browser/out/browser/index.mjs +3746 -0
- package/browser/out/browser/index.mjs.map +7 -0
- package/browser/out/core/collectErrors.d.ts +36 -0
- package/browser/out/core/collectErrors.d.ts.map +1 -0
- package/browser/out/core/configSchema.d.ts +63 -0
- package/browser/out/core/configSchema.d.ts.map +1 -0
- package/browser/out/core/customCodeTags.d.ts +34 -0
- package/browser/out/core/customCodeTags.d.ts.map +1 -0
- package/browser/out/core/diagnostic.d.ts +24 -0
- package/browser/out/core/diagnostic.d.ts.map +1 -0
- package/browser/out/core/embeddedRegions.d.ts +12 -0
- package/browser/out/core/embeddedRegions.d.ts.map +1 -0
- package/browser/out/core/formatting/classifier.d.ts +68 -0
- package/browser/out/core/formatting/classifier.d.ts.map +1 -0
- package/browser/out/core/formatting/embedded.d.ts +19 -0
- package/browser/out/core/formatting/embedded.d.ts.map +1 -0
- package/browser/out/core/formatting/formatters.d.ts +85 -0
- package/browser/out/core/formatting/formatters.d.ts.map +1 -0
- package/browser/out/core/formatting/index.d.ts +44 -0
- package/browser/out/core/formatting/index.d.ts.map +1 -0
- package/browser/out/core/formatting/ir.d.ts +100 -0
- package/browser/out/core/formatting/ir.d.ts.map +1 -0
- package/browser/out/core/formatting/mergeOptions.d.ts +18 -0
- package/browser/out/core/formatting/mergeOptions.d.ts.map +1 -0
- package/browser/out/core/formatting/printer.d.ts +18 -0
- package/browser/out/core/formatting/printer.d.ts.map +1 -0
- package/browser/out/core/formatting/utils.d.ts +39 -0
- package/browser/out/core/formatting/utils.d.ts.map +1 -0
- package/browser/out/core/grammar.d.ts +3 -0
- package/browser/out/core/grammar.d.ts.map +1 -0
- package/browser/out/core/htmlBalanceChecker.d.ts +23 -0
- package/browser/out/core/htmlBalanceChecker.d.ts.map +1 -0
- package/browser/out/core/mustacheChecks.d.ts +24 -0
- package/browser/out/core/mustacheChecks.d.ts.map +1 -0
- package/browser/out/core/nodeHelpers.d.ts +54 -0
- package/browser/out/core/nodeHelpers.d.ts.map +1 -0
- package/browser/out/core/ruleMetadata.d.ts +12 -0
- package/browser/out/core/ruleMetadata.d.ts.map +1 -0
- package/browser/out/core/selectorMatcher.d.ts +87 -0
- package/browser/out/core/selectorMatcher.d.ts.map +1 -0
- package/cli/out/main.js +333 -181
- package/package.json +21 -3
- package/src/browser/browser.test.ts +207 -0
- package/src/browser/index.ts +128 -0
- package/src/browser/tsconfig.json +18 -0
- package/src/core/collectErrors.ts +233 -0
- package/src/core/configSchema.ts +273 -0
- package/src/core/customCodeTags.ts +159 -0
- package/src/core/diagnostic.ts +45 -0
- package/src/core/embeddedRegions.ts +70 -0
- package/src/core/formatting/classifier.ts +549 -0
- package/src/core/formatting/embedded.ts +56 -0
- package/src/core/formatting/formatters.ts +1272 -0
- package/src/core/formatting/index.ts +185 -0
- package/src/core/formatting/ir.ts +202 -0
- package/src/core/formatting/mergeOptions.ts +34 -0
- package/src/core/formatting/printer.ts +242 -0
- package/src/core/formatting/utils.ts +193 -0
- package/src/core/grammar.ts +2 -0
- package/src/core/htmlBalanceChecker.ts +382 -0
- package/src/core/mustacheChecks.ts +504 -0
- package/src/core/nodeHelpers.ts +126 -0
- package/src/core/ruleMetadata.ts +63 -0
- package/src/core/selectorMatcher.ts +919 -0
|
@@ -0,0 +1,919 @@
|
|
|
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), child (`>`), adjacent-sibling (`+`), and
|
|
14
|
+
* general-sibling (`~`) combinators. Sibling combinators skip over text /
|
|
15
|
+
* whitespace nodes (CSS semantics) and work across HTML and Mustache
|
|
16
|
+
* constructs: e.g. `label + input`, `{{foo}} + p`, `h2 ~ {{#items}}`.
|
|
17
|
+
* - Mustache variables: `{{path}}` and `{{{path}}}` (raw)
|
|
18
|
+
* - Mustache sections: `{{#name}}` and `{{^name}}` (inverted)
|
|
19
|
+
* - Mustache comments: `{{!content}}`
|
|
20
|
+
* - Mustache partials: `{{>name}}`
|
|
21
|
+
* - Glob wildcard `*` inside the argument: `{{options.*}}`, `{{*.deprecated}}`, `{{*}}`
|
|
22
|
+
* - `:has(selector)` — element has a matching descendant
|
|
23
|
+
* - `:not(...)` — negation. Accepts attributes/class/id/`:has` (folded into
|
|
24
|
+
* the outer compound), plus any other selector (including Mustache
|
|
25
|
+
* literals and type selectors) as a whole-selector check against the
|
|
26
|
+
* node itself. Example: `{{*}}:not({{internal.*}})` matches any
|
|
27
|
+
* interpolation whose path does not start with `internal.`.
|
|
28
|
+
* - `:root` — the tree-sitter fragment root (the whole document). Unlike
|
|
29
|
+
* browser CSS where `:root` matches `<html>`, this matches the parse-tree
|
|
30
|
+
* root so it works on partials/fragments too. Useful as a document-scoped
|
|
31
|
+
* anchor, e.g. `:root:has(pl-question-panel):not(:has(pl-answer-panel))`
|
|
32
|
+
* matches the root iff a `pl-question-panel` is present anywhere but no
|
|
33
|
+
* `pl-answer-panel` is. Cannot combine with tag/class/id/attribute in the
|
|
34
|
+
* same compound (only with `:has` / `:not(:has(...))`). Inside `:has(...)`,
|
|
35
|
+
* `:root` refers to the element being checked, not the document.
|
|
36
|
+
* - `:is(a, b, ...)` — matches if any alternative matches. Expanded at parse
|
|
37
|
+
* time into the Cartesian product of alternatives, so `:is(a, b) :is(c, d)`
|
|
38
|
+
* is equivalent to `a c, a d, b c, b d`. Alternatives inside `:is` that
|
|
39
|
+
* contain combinators are only allowed when the `:is(...)` stands alone in
|
|
40
|
+
* its compound (e.g. `:is(div > span, p)` works; `x:is(div > span, p)`
|
|
41
|
+
* does not, since a combinator can't be merged into another compound).
|
|
42
|
+
* - Comma-separated alternatives
|
|
43
|
+
*
|
|
44
|
+
* Unsupported (parseSelector returns null, rule is skipped):
|
|
45
|
+
* - `[attr|=v]`, case-insensitive `i` flag
|
|
46
|
+
* - Mixed HTML + Mustache kinds in one compound (e.g. `img{{foo}}`)
|
|
47
|
+
* - `{{/end}}` (end tags aren't standalone nodes)
|
|
48
|
+
* - `{{=<% %>=}}` (delimiter changes aren't grammar-tracked)
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { parse as parselParse, type AST, type Token, type AttributeToken, type ClassToken, type IdToken } from 'parsel-js';
|
|
52
|
+
import type { BalanceNode } from './htmlBalanceChecker.js';
|
|
53
|
+
import {
|
|
54
|
+
getTagName,
|
|
55
|
+
getSectionName,
|
|
56
|
+
getInterpolationPath,
|
|
57
|
+
getCommentContent,
|
|
58
|
+
getPartialName,
|
|
59
|
+
HTML_ELEMENT_TYPES,
|
|
60
|
+
} from './nodeHelpers.js';
|
|
61
|
+
|
|
62
|
+
// --- Types ---
|
|
63
|
+
|
|
64
|
+
export type AttributeOperator = '=' | '^=' | '*=' | '$=' | '~=';
|
|
65
|
+
|
|
66
|
+
export type SegmentKind =
|
|
67
|
+
| 'html'
|
|
68
|
+
| 'section'
|
|
69
|
+
| 'inverted'
|
|
70
|
+
| 'variable'
|
|
71
|
+
| 'raw'
|
|
72
|
+
| 'comment'
|
|
73
|
+
| 'partial';
|
|
74
|
+
|
|
75
|
+
export interface AttributeConstraint {
|
|
76
|
+
name: string; // lowercased
|
|
77
|
+
op: AttributeOperator; // meaningful only when value !== undefined
|
|
78
|
+
value?: string; // quotes stripped; undefined => presence check
|
|
79
|
+
negated: boolean; // true when inside :not()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DescendantCheck {
|
|
83
|
+
selector: ParsedSelector; // :has(selector) — must match a descendant
|
|
84
|
+
negated: boolean; // true for :not(:has(...))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type Combinator = 'descendant' | 'child' | 'adjacent-sibling' | 'general-sibling';
|
|
88
|
+
|
|
89
|
+
export interface Segment {
|
|
90
|
+
kind: SegmentKind;
|
|
91
|
+
rootOnly: boolean; // true for `:root` — matches only the tree-sitter fragment root
|
|
92
|
+
name: string | null; // lowercased identifier/path, null = wildcard
|
|
93
|
+
pathRegex?: RegExp; // compiled glob when `name` contains `*`
|
|
94
|
+
attributes: AttributeConstraint[];
|
|
95
|
+
descendantChecks: DescendantCheck[];
|
|
96
|
+
selfNegations: ParsedSelector[]; // :not(X) where X is a full sub-selector tested against the node itself
|
|
97
|
+
combinator: Combinator;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A parsed selector is a list of alternatives (from comma-separated parts). */
|
|
101
|
+
export type ParsedSelector = Segment[][];
|
|
102
|
+
|
|
103
|
+
// --- Mustache preprocessor ---
|
|
104
|
+
|
|
105
|
+
const MUSTACHE_KIND_PSEUDO = new Set([
|
|
106
|
+
'm-section', 'm-inverted', 'm-variable', 'm-raw', 'm-comment', 'm-partial',
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rewrite Mustache-literal tokens (`{{...}}` forms) in the selector string into
|
|
111
|
+
* internal `:m-*` pseudo-class markers so parsel-js can handle them. Returns
|
|
112
|
+
* null if the string contains an unsupported or malformed Mustache token.
|
|
113
|
+
*
|
|
114
|
+
* Skips content inside `"..."` and `'...'` so that literal `{{...}}` embedded
|
|
115
|
+
* in CSS attribute-value strings is preserved unchanged.
|
|
116
|
+
*/
|
|
117
|
+
export function preprocessMustacheLiterals(raw: string): string | null {
|
|
118
|
+
let out = '';
|
|
119
|
+
let i = 0;
|
|
120
|
+
const len = raw.length;
|
|
121
|
+
|
|
122
|
+
while (i < len) {
|
|
123
|
+
const ch = raw[i];
|
|
124
|
+
|
|
125
|
+
// Pass through quoted strings verbatim.
|
|
126
|
+
if (ch === '"' || ch === "'") {
|
|
127
|
+
out += ch;
|
|
128
|
+
i++;
|
|
129
|
+
while (i < len && raw[i] !== ch) {
|
|
130
|
+
if (raw[i] === '\\' && i + 1 < len) {
|
|
131
|
+
out += raw[i] + raw[i + 1];
|
|
132
|
+
i += 2;
|
|
133
|
+
} else {
|
|
134
|
+
out += raw[i];
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (i < len) {
|
|
139
|
+
out += raw[i]; // closing quote
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (ch !== '{' || raw[i + 1] !== '{') {
|
|
146
|
+
out += ch;
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Triple-brace: {{{path}}}
|
|
152
|
+
if (raw[i + 2] === '{') {
|
|
153
|
+
const end = raw.indexOf('}}}', i + 3);
|
|
154
|
+
if (end < 0) return null;
|
|
155
|
+
const inner = raw.slice(i + 3, end).trim();
|
|
156
|
+
if (inner.length === 0) return null;
|
|
157
|
+
out += `:m-raw(${inner})`;
|
|
158
|
+
i = end + 3;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Double-brace: {{...}}
|
|
163
|
+
const end = raw.indexOf('}}', i + 2);
|
|
164
|
+
if (end < 0) return null;
|
|
165
|
+
const body = raw.slice(i + 2, end);
|
|
166
|
+
i = end + 2;
|
|
167
|
+
|
|
168
|
+
const sigil = body.trimStart()[0];
|
|
169
|
+
const content = body.replace(/^\s*[#^!>/]\s*/, '').replace(/^\s+|\s+$/g, '');
|
|
170
|
+
|
|
171
|
+
switch (sigil) {
|
|
172
|
+
case '#':
|
|
173
|
+
if (content.length === 0) return null;
|
|
174
|
+
out += `:m-section(${content})`;
|
|
175
|
+
break;
|
|
176
|
+
case '^':
|
|
177
|
+
if (content.length === 0) return null;
|
|
178
|
+
out += `:m-inverted(${content})`;
|
|
179
|
+
break;
|
|
180
|
+
case '!':
|
|
181
|
+
if (content.length === 0) return null;
|
|
182
|
+
out += `:m-comment(${content})`;
|
|
183
|
+
break;
|
|
184
|
+
case '>':
|
|
185
|
+
if (content.length === 0) return null;
|
|
186
|
+
out += `:m-partial(${content})`;
|
|
187
|
+
break;
|
|
188
|
+
case '/':
|
|
189
|
+
// Standalone end tags are not a selectable node.
|
|
190
|
+
return null;
|
|
191
|
+
case '=':
|
|
192
|
+
// Delimiter changes are not a grammar-tracked node.
|
|
193
|
+
return null;
|
|
194
|
+
default: {
|
|
195
|
+
const path = body.trim();
|
|
196
|
+
if (path.length === 0) return null;
|
|
197
|
+
out += `:m-variable(${path})`;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Selector parsing ---
|
|
207
|
+
|
|
208
|
+
export function parseSelector(raw: string): ParsedSelector | null {
|
|
209
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
210
|
+
|
|
211
|
+
const preprocessed = preprocessMustacheLiterals(raw);
|
|
212
|
+
if (preprocessed === null) return null;
|
|
213
|
+
|
|
214
|
+
let ast: AST | undefined;
|
|
215
|
+
try {
|
|
216
|
+
ast = parselParse(preprocessed);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (!ast) return null;
|
|
221
|
+
|
|
222
|
+
const tops = ast.type === 'list' ? ast.list : [ast];
|
|
223
|
+
const alts: Segment[][] = [];
|
|
224
|
+
for (const top of tops) {
|
|
225
|
+
const expanded = expandIs(top);
|
|
226
|
+
if (expanded === null) return null;
|
|
227
|
+
for (const exp of expanded) {
|
|
228
|
+
const segments: Segment[] = [];
|
|
229
|
+
if (!collectSegments(exp, 'descendant', segments)) return null;
|
|
230
|
+
if (segments.length === 0) return null;
|
|
231
|
+
alts.push(segments);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return alts.length > 0 ? alts : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Expand `:is(...)` pseudo-classes into explicit alternatives. Returns an
|
|
239
|
+
* array of equivalent ASTs with every `:is` removed (Cartesian product over
|
|
240
|
+
* alternatives), or `null` if the expansion contains an unsupported shape
|
|
241
|
+
* (e.g. complex alternatives inside a mixed compound).
|
|
242
|
+
*/
|
|
243
|
+
function expandIs(ast: AST): AST[] | null {
|
|
244
|
+
switch (ast.type) {
|
|
245
|
+
case 'list': {
|
|
246
|
+
const out: AST[] = [];
|
|
247
|
+
for (const alt of ast.list) {
|
|
248
|
+
const expanded = expandIs(alt);
|
|
249
|
+
if (expanded === null) return null;
|
|
250
|
+
out.push(...expanded);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
case 'complex': {
|
|
255
|
+
const lefts = expandIs(ast.left);
|
|
256
|
+
if (lefts === null) return null;
|
|
257
|
+
const rights = expandIs(ast.right);
|
|
258
|
+
if (rights === null) return null;
|
|
259
|
+
const out: AST[] = [];
|
|
260
|
+
for (const l of lefts) for (const r of rights) {
|
|
261
|
+
out.push({ ...ast, left: l, right: r });
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
case 'compound': {
|
|
266
|
+
// A compound whose sole token is `:is(...)` can be replaced by any
|
|
267
|
+
// shape (including complex alternatives). Mixed compounds require each
|
|
268
|
+
// alternative to be mergeable token-by-token.
|
|
269
|
+
if (ast.list.length === 1) {
|
|
270
|
+
const tok = ast.list[0];
|
|
271
|
+
if (tok.type === 'pseudo-class' && tok.name === 'is') {
|
|
272
|
+
if (!tok.subtree) return null;
|
|
273
|
+
return expandIs(tok.subtree);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return expandCompoundWithIs(ast.list);
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
if (ast.type === 'pseudo-class' && ast.name === 'is') {
|
|
280
|
+
if (!ast.subtree) return null;
|
|
281
|
+
return expandIs(ast.subtree);
|
|
282
|
+
}
|
|
283
|
+
return [ast];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function expandCompoundWithIs(tokens: Token[]): AST[] | null {
|
|
288
|
+
let variants: Token[][] = [[]];
|
|
289
|
+
for (const tok of tokens) {
|
|
290
|
+
if (tok.type === 'pseudo-class' && tok.name === 'is') {
|
|
291
|
+
if (!tok.subtree) return null;
|
|
292
|
+
const alts = expandIs(tok.subtree);
|
|
293
|
+
if (alts === null) return null;
|
|
294
|
+
const next: Token[][] = [];
|
|
295
|
+
for (const base of variants) {
|
|
296
|
+
for (const alt of alts) {
|
|
297
|
+
if (alt.type === 'compound') {
|
|
298
|
+
next.push([...base, ...alt.list]);
|
|
299
|
+
} else if (alt.type === 'complex' || alt.type === 'list' || alt.type === 'relative') {
|
|
300
|
+
// Can't splice a combinator-bearing selector into a compound.
|
|
301
|
+
return null;
|
|
302
|
+
} else {
|
|
303
|
+
next.push([...base, alt]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
variants = next;
|
|
308
|
+
} else {
|
|
309
|
+
variants = variants.map(v => [...v, tok]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return variants.map(list =>
|
|
313
|
+
list.length === 1 ? (list[0] as AST) : ({ type: 'compound', list } as AST),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function collectSegments(
|
|
318
|
+
ast: AST,
|
|
319
|
+
combinator: Combinator,
|
|
320
|
+
out: Segment[],
|
|
321
|
+
): boolean {
|
|
322
|
+
if (ast.type === 'complex') {
|
|
323
|
+
const mapped = mapCombinator(ast.combinator);
|
|
324
|
+
if (!mapped) return false;
|
|
325
|
+
return collectSegments(ast.left, 'descendant', out)
|
|
326
|
+
&& collectSegments(ast.right, mapped, out);
|
|
327
|
+
}
|
|
328
|
+
if (ast.type === 'list' || ast.type === 'relative') return false;
|
|
329
|
+
|
|
330
|
+
const segment = segmentFromCompound(ast);
|
|
331
|
+
if (!segment) return false;
|
|
332
|
+
segment.combinator = combinator;
|
|
333
|
+
out.push(segment);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function mapCombinator(c: string): Combinator | null {
|
|
338
|
+
const trimmed = c.trim();
|
|
339
|
+
if (trimmed === '') return 'descendant';
|
|
340
|
+
if (trimmed === '>') return 'child';
|
|
341
|
+
if (trimmed === '+') return 'adjacent-sibling';
|
|
342
|
+
if (trimmed === '~') return 'general-sibling';
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function segmentFromCompound(ast: AST): Segment | null {
|
|
347
|
+
const tokens: Token[] = ast.type === 'compound' ? ast.list : [ast as Token];
|
|
348
|
+
|
|
349
|
+
let kind: SegmentKind | undefined;
|
|
350
|
+
let name: string | null = null;
|
|
351
|
+
let pathRegex: RegExp | undefined;
|
|
352
|
+
let rootOnly = false;
|
|
353
|
+
const attributes: AttributeConstraint[] = [];
|
|
354
|
+
const descendantChecks: DescendantCheck[] = [];
|
|
355
|
+
const selfNegations: ParsedSelector[] = [];
|
|
356
|
+
|
|
357
|
+
// Once a Mustache kind is picked, no other kind tokens may appear.
|
|
358
|
+
const forbidChange = (requested: SegmentKind): boolean => {
|
|
359
|
+
if (kind === undefined) return false;
|
|
360
|
+
if (kind === requested) return false;
|
|
361
|
+
// html and Mustache kinds never mix
|
|
362
|
+
return true;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
for (const token of tokens) {
|
|
366
|
+
switch (token.type) {
|
|
367
|
+
case 'type':
|
|
368
|
+
if (forbidChange('html')) return null;
|
|
369
|
+
kind = 'html';
|
|
370
|
+
name = token.name.toLowerCase();
|
|
371
|
+
break;
|
|
372
|
+
case 'universal':
|
|
373
|
+
if (forbidChange('html')) return null;
|
|
374
|
+
kind = 'html';
|
|
375
|
+
name = null;
|
|
376
|
+
break;
|
|
377
|
+
case 'class':
|
|
378
|
+
if (forbidChange('html')) return null;
|
|
379
|
+
kind = 'html';
|
|
380
|
+
attributes.push(classConstraint(token, false));
|
|
381
|
+
break;
|
|
382
|
+
case 'id':
|
|
383
|
+
if (forbidChange('html')) return null;
|
|
384
|
+
kind = 'html';
|
|
385
|
+
attributes.push(idConstraint(token, false));
|
|
386
|
+
break;
|
|
387
|
+
case 'attribute': {
|
|
388
|
+
// Attribute selectors only apply to HTML segments.
|
|
389
|
+
if (forbidChange('html')) return null;
|
|
390
|
+
if (kind === undefined) kind = 'html';
|
|
391
|
+
const c = attributeConstraint(token, false);
|
|
392
|
+
if (!c) return null;
|
|
393
|
+
attributes.push(c);
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case 'pseudo-class': {
|
|
397
|
+
if (MUSTACHE_KIND_PSEUDO.has(token.name)) {
|
|
398
|
+
const mustacheKind = mustacheKindFromMarker(token.name);
|
|
399
|
+
if (mustacheKind === null) return null;
|
|
400
|
+
if (forbidChange(mustacheKind)) return null;
|
|
401
|
+
kind = mustacheKind;
|
|
402
|
+
const glob = parseGlob(token.argument ?? '');
|
|
403
|
+
name = glob.name;
|
|
404
|
+
pathRegex = glob.pathRegex;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
if (token.name === 'has') {
|
|
408
|
+
const sel = subtreeToSelector(token.subtree);
|
|
409
|
+
if (!sel) return null;
|
|
410
|
+
descendantChecks.push({ selector: sel, negated: false });
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
if (token.name === 'not') {
|
|
414
|
+
if (!applyNegatedSubtree(token.subtree, attributes, descendantChecks, selfNegations)) return null;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
if (token.name === 'root') {
|
|
418
|
+
rootOnly = true;
|
|
419
|
+
if (kind === undefined) kind = 'html';
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
default:
|
|
425
|
+
// pseudo-element, comma, combinator, unknown → unsupported
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (kind === undefined) {
|
|
431
|
+
kind = 'html';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (rootOnly) {
|
|
435
|
+
// `:root` is only meaningful on its own (optionally with :has / :not(:has)).
|
|
436
|
+
// Reject tag/attribute/class/id combinations — the root isn't an HTML element.
|
|
437
|
+
if (name !== null || attributes.length > 0 || kind !== 'html') return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const isHtml = kind === 'html';
|
|
441
|
+
const finalAttrs = isHtml ? attributes : [];
|
|
442
|
+
return { kind, rootOnly, name, pathRegex, attributes: finalAttrs, descendantChecks, selfNegations, combinator: 'descendant' };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function mustacheKindFromMarker(name: string): SegmentKind | null {
|
|
446
|
+
switch (name) {
|
|
447
|
+
case 'm-section': return 'section';
|
|
448
|
+
case 'm-inverted': return 'inverted';
|
|
449
|
+
case 'm-variable': return 'variable';
|
|
450
|
+
case 'm-raw': return 'raw';
|
|
451
|
+
case 'm-comment': return 'comment';
|
|
452
|
+
case 'm-partial': return 'partial';
|
|
453
|
+
default: return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Parse a Mustache-literal argument into an exact name or a compiled glob.
|
|
459
|
+
* The '*' character is the only wildcard. Bare '*' or empty string returns
|
|
460
|
+
* { name: null } — a wildcard that matches any value.
|
|
461
|
+
*/
|
|
462
|
+
function parseGlob(arg: string): { name: string | null; pathRegex?: RegExp } {
|
|
463
|
+
const trimmed = arg.trim();
|
|
464
|
+
if (trimmed === '' || trimmed === '*') {
|
|
465
|
+
return { name: null }; // wildcard — matches anything
|
|
466
|
+
}
|
|
467
|
+
if (!trimmed.includes('*')) {
|
|
468
|
+
return { name: trimmed.toLowerCase() };
|
|
469
|
+
}
|
|
470
|
+
// Escape regex metacharacters except `*`, then substitute `*` → `.*`.
|
|
471
|
+
const escaped = trimmed.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
472
|
+
const pathRegex = new RegExp(`^${escaped}$`, 'i');
|
|
473
|
+
return { name: trimmed.toLowerCase(), pathRegex };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function attributeConstraint(token: AttributeToken, negated: boolean): AttributeConstraint | null {
|
|
477
|
+
const name = token.name.toLowerCase();
|
|
478
|
+
if (token.operator === undefined) {
|
|
479
|
+
return { name, op: '=', value: undefined, negated };
|
|
480
|
+
}
|
|
481
|
+
let op: AttributeOperator;
|
|
482
|
+
switch (token.operator) {
|
|
483
|
+
case '=': op = '='; break;
|
|
484
|
+
case '^=': op = '^='; break;
|
|
485
|
+
case '*=': op = '*='; break;
|
|
486
|
+
case '$=': op = '$='; break;
|
|
487
|
+
case '~=': op = '~='; break;
|
|
488
|
+
default: return null;
|
|
489
|
+
}
|
|
490
|
+
return { name, op, value: stripQuotes(token.value ?? ''), negated };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function classConstraint(token: ClassToken, negated: boolean): AttributeConstraint {
|
|
494
|
+
return { name: 'class', op: '~=', value: token.name, negated };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function idConstraint(token: IdToken, negated: boolean): AttributeConstraint {
|
|
498
|
+
return { name: 'id', op: '=', value: token.name, negated };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function applyNegatedSubtree(
|
|
502
|
+
subtree: AST | undefined,
|
|
503
|
+
attributes: AttributeConstraint[],
|
|
504
|
+
descendantChecks: DescendantCheck[],
|
|
505
|
+
selfNegations: ParsedSelector[],
|
|
506
|
+
): boolean {
|
|
507
|
+
if (!subtree) return false;
|
|
508
|
+
if (subtree.type === 'attribute') {
|
|
509
|
+
const c = attributeConstraint(subtree, true);
|
|
510
|
+
if (!c) return false;
|
|
511
|
+
attributes.push(c);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
if (subtree.type === 'class') {
|
|
515
|
+
attributes.push(classConstraint(subtree, true));
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
if (subtree.type === 'id') {
|
|
519
|
+
attributes.push(idConstraint(subtree, true));
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
if (subtree.type === 'pseudo-class' && subtree.name === 'has') {
|
|
523
|
+
const sel = subtreeToSelector(subtree.subtree);
|
|
524
|
+
if (!sel) return false;
|
|
525
|
+
descendantChecks.push({ selector: sel, negated: true });
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
// Fall back to a whole-selector negation: `:not(X)` where X is a Mustache
|
|
529
|
+
// literal or any other parseable selector, tested against the node itself.
|
|
530
|
+
const sel = subtreeToSelector(subtree);
|
|
531
|
+
if (sel) {
|
|
532
|
+
selfNegations.push(sel);
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function subtreeToSelector(subtree: AST | undefined): ParsedSelector | null {
|
|
539
|
+
if (!subtree) return null;
|
|
540
|
+
const tops = subtree.type === 'list' ? subtree.list : [subtree];
|
|
541
|
+
const alts: Segment[][] = [];
|
|
542
|
+
for (const top of tops) {
|
|
543
|
+
const segments: Segment[] = [];
|
|
544
|
+
if (!collectSegments(top, 'descendant', segments)) return null;
|
|
545
|
+
if (segments.length === 0) return null;
|
|
546
|
+
alts.push(segments);
|
|
547
|
+
}
|
|
548
|
+
return alts.length > 0 ? alts : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function stripQuotes(raw: string): string {
|
|
552
|
+
if (raw.length < 2) return raw;
|
|
553
|
+
const first = raw[0];
|
|
554
|
+
const last = raw[raw.length - 1];
|
|
555
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
556
|
+
return raw.slice(1, -1);
|
|
557
|
+
}
|
|
558
|
+
return raw;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// --- Tree matching ---
|
|
562
|
+
|
|
563
|
+
interface AncestorEntry {
|
|
564
|
+
kind: AncestorKind;
|
|
565
|
+
name: string; // lowercased
|
|
566
|
+
node: BalanceNode;
|
|
567
|
+
siblings: BalanceNode[]; // the node's parent's children; empty for root
|
|
568
|
+
indexInSiblings: number; // 0 for root
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
type AncestorKind = 'html' | 'section' | 'inverted' | 'root';
|
|
572
|
+
|
|
573
|
+
function ancestorKindForNode(node: BalanceNode): AncestorKind | null {
|
|
574
|
+
if (HTML_ELEMENT_TYPES.has(node.type)) return 'html';
|
|
575
|
+
if (node.type === 'mustache_section') return 'section';
|
|
576
|
+
if (node.type === 'mustache_inverted_section') return 'inverted';
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function getHtmlAttributes(node: BalanceNode): { name: string; value?: string }[] {
|
|
581
|
+
const startTag = node.children.find(
|
|
582
|
+
c => c.type === 'html_start_tag' || c.type === 'html_self_closing_tag',
|
|
583
|
+
);
|
|
584
|
+
if (!startTag) return [];
|
|
585
|
+
|
|
586
|
+
const attrs: { name: string; value?: string }[] = [];
|
|
587
|
+
for (const child of startTag.children) {
|
|
588
|
+
if (child.type !== 'html_attribute') continue;
|
|
589
|
+
let attrName = '';
|
|
590
|
+
let attrValue: string | undefined;
|
|
591
|
+
for (const part of child.children) {
|
|
592
|
+
if (part.type === 'html_attribute_name') {
|
|
593
|
+
attrName = part.text.toLowerCase();
|
|
594
|
+
} else if (part.type === 'html_quoted_attribute_value') {
|
|
595
|
+
attrValue = part.text.replace(/^["']|["']$/g, '');
|
|
596
|
+
} else if (part.type === 'html_attribute_value') {
|
|
597
|
+
attrValue = part.text;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (attrName) attrs.push({ name: attrName, value: attrValue });
|
|
601
|
+
}
|
|
602
|
+
return attrs;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function matchesAttributeValue(has: string | undefined, c: AttributeConstraint): boolean {
|
|
606
|
+
if (has === undefined || c.value === undefined) return false;
|
|
607
|
+
const v = c.value;
|
|
608
|
+
if (v === '') return false;
|
|
609
|
+
switch (c.op) {
|
|
610
|
+
case '=': return has === v;
|
|
611
|
+
case '^=': return has.startsWith(v);
|
|
612
|
+
case '*=': return has.includes(v);
|
|
613
|
+
case '$=': return has.endsWith(v);
|
|
614
|
+
case '~=': return has.split(/\s+/).includes(v);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function checkAttributes(node: BalanceNode, constraints: AttributeConstraint[]): boolean {
|
|
619
|
+
if (constraints.length === 0) return true;
|
|
620
|
+
const nodeAttrs = getHtmlAttributes(node);
|
|
621
|
+
for (const c of constraints) {
|
|
622
|
+
const found = nodeAttrs.find(a => a.name === c.name);
|
|
623
|
+
if (c.negated) {
|
|
624
|
+
if (!found) continue;
|
|
625
|
+
if (c.value === undefined) return false;
|
|
626
|
+
if (matchesAttributeValue(found.value, c)) return false;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (c.value === undefined) {
|
|
630
|
+
if (!found) return false;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (!found || !matchesAttributeValue(found.value, c)) return false;
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function checkDescendants(node: BalanceNode, checks: DescendantCheck[]): boolean {
|
|
639
|
+
if (checks.length === 0) return true;
|
|
640
|
+
for (const check of checks) {
|
|
641
|
+
const present = hasDescendantMatch(node, check.selector);
|
|
642
|
+
if (check.negated ? present : !present) return false;
|
|
643
|
+
}
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function hasDescendantMatch(node: BalanceNode, selector: ParsedSelector): boolean {
|
|
648
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
649
|
+
if (matchSelector(node.children[i], selector, node.children, i).length > 0) return true;
|
|
650
|
+
}
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function checkSelfNegations(node: BalanceNode, negations: ParsedSelector[], rootNode: BalanceNode): boolean {
|
|
655
|
+
for (const sel of negations) {
|
|
656
|
+
for (const alt of sel) {
|
|
657
|
+
// A selfNegation selector is a single-segment check against the node itself.
|
|
658
|
+
// Multi-segment alternatives (e.g. `:not(a b)`) aren't sensibly testable
|
|
659
|
+
// against a single node, so they're treated as never matching => pass.
|
|
660
|
+
if (alt.length !== 1) continue;
|
|
661
|
+
if (nodeMatchesSegment(node, alt[0], rootNode)) return false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function matchesName(actual: string | null, segment: Segment): boolean {
|
|
668
|
+
if (segment.name === null) return true; // wildcard
|
|
669
|
+
if (actual === null) return false;
|
|
670
|
+
if (segment.pathRegex) return segment.pathRegex.test(actual);
|
|
671
|
+
return actual === segment.name;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function nodeMatchesSegment(node: BalanceNode, segment: Segment, rootNode: BalanceNode): boolean {
|
|
675
|
+
if (segment.rootOnly) {
|
|
676
|
+
if (node !== rootNode) return false;
|
|
677
|
+
return checkDescendants(node, segment.descendantChecks)
|
|
678
|
+
&& checkSelfNegations(node, segment.selfNegations, rootNode);
|
|
679
|
+
}
|
|
680
|
+
const baseMatches = (() => {
|
|
681
|
+
switch (segment.kind) {
|
|
682
|
+
case 'html': {
|
|
683
|
+
if (!HTML_ELEMENT_TYPES.has(node.type)) return false;
|
|
684
|
+
if (segment.name !== null) {
|
|
685
|
+
const tagName = getTagName(node)?.toLowerCase();
|
|
686
|
+
if (tagName !== segment.name) return false;
|
|
687
|
+
}
|
|
688
|
+
return checkAttributes(node, segment.attributes) && checkDescendants(node, segment.descendantChecks);
|
|
689
|
+
}
|
|
690
|
+
case 'section':
|
|
691
|
+
if (node.type !== 'mustache_section') return false;
|
|
692
|
+
if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
|
|
693
|
+
return checkDescendants(node, segment.descendantChecks);
|
|
694
|
+
case 'inverted':
|
|
695
|
+
if (node.type !== 'mustache_inverted_section') return false;
|
|
696
|
+
if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
|
|
697
|
+
return checkDescendants(node, segment.descendantChecks);
|
|
698
|
+
case 'variable':
|
|
699
|
+
if (node.type !== 'mustache_interpolation') return false;
|
|
700
|
+
if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
|
|
701
|
+
return checkDescendants(node, segment.descendantChecks);
|
|
702
|
+
case 'raw':
|
|
703
|
+
if (node.type !== 'mustache_triple') return false;
|
|
704
|
+
if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
|
|
705
|
+
return checkDescendants(node, segment.descendantChecks);
|
|
706
|
+
case 'comment':
|
|
707
|
+
if (node.type !== 'mustache_comment') return false;
|
|
708
|
+
if (!matchesName(getCommentContent(node)?.toLowerCase() ?? null, segment)) return false;
|
|
709
|
+
return checkDescendants(node, segment.descendantChecks);
|
|
710
|
+
case 'partial':
|
|
711
|
+
if (node.type !== 'mustache_partial') return false;
|
|
712
|
+
if (!matchesName(getPartialName(node)?.toLowerCase() ?? null, segment)) return false;
|
|
713
|
+
return checkDescendants(node, segment.descendantChecks);
|
|
714
|
+
}
|
|
715
|
+
})();
|
|
716
|
+
if (!baseMatches) return false;
|
|
717
|
+
return checkSelfNegations(node, segment.selfNegations, rootNode);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
interface Cursor {
|
|
721
|
+
ancestors: AncestorEntry[]; // nodes on the path from root, excluding the match pointer itself
|
|
722
|
+
siblings: BalanceNode[]; // siblings of the current match pointer
|
|
723
|
+
indexInSiblings: number; // index of the current match pointer in its siblings
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Does the prefix of segments up to segIdx satisfy the path/sibling context? */
|
|
727
|
+
function checkPrefix(
|
|
728
|
+
cursor: Cursor,
|
|
729
|
+
segments: Segment[],
|
|
730
|
+
segIdx: number,
|
|
731
|
+
stepCombinator: Combinator,
|
|
732
|
+
rootNode: BalanceNode,
|
|
733
|
+
): boolean {
|
|
734
|
+
if (segIdx < 0) return true;
|
|
735
|
+
const segment = segments[segIdx];
|
|
736
|
+
|
|
737
|
+
if (stepCombinator === 'adjacent-sibling' || stepCombinator === 'general-sibling') {
|
|
738
|
+
for (let i = cursor.indexInSiblings - 1; i >= 0; i--) {
|
|
739
|
+
const sib = cursor.siblings[i];
|
|
740
|
+
if (!isMatchableNode(sib)) continue;
|
|
741
|
+
if (!nodeMatchesSegment(sib, segment, rootNode)) {
|
|
742
|
+
if (stepCombinator === 'adjacent-sibling') return false;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const newCursor: Cursor = {
|
|
746
|
+
ancestors: cursor.ancestors,
|
|
747
|
+
siblings: cursor.siblings,
|
|
748
|
+
indexInSiblings: i,
|
|
749
|
+
};
|
|
750
|
+
if (checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
|
|
751
|
+
if (stepCombinator === 'adjacent-sibling') return false;
|
|
752
|
+
}
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// descendant or child — walk up the ancestor stack
|
|
757
|
+
const ancestorKind = ancestorKindForSegment(segment);
|
|
758
|
+
if (ancestorKind === null) return false; // variable/raw/comment/partial can't be ancestors
|
|
759
|
+
|
|
760
|
+
if (stepCombinator === 'child') {
|
|
761
|
+
for (let a = cursor.ancestors.length - 1; a >= 0; a--) {
|
|
762
|
+
const entry = cursor.ancestors[a];
|
|
763
|
+
if (entry.kind !== ancestorKind) {
|
|
764
|
+
// For `:root > X` (ancestorKind='root'), any html ancestor between
|
|
765
|
+
// X and the document root breaks the direct-child relationship.
|
|
766
|
+
// Mustache sections are transparent (existing braided semantics).
|
|
767
|
+
if (ancestorKind === 'root' && entry.kind === 'html') return false;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (!matchesName(entry.name, segment)) return false;
|
|
771
|
+
if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) return false;
|
|
772
|
+
if (!checkDescendants(entry.node, segment.descendantChecks)) return false;
|
|
773
|
+
if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode)) return false;
|
|
774
|
+
const newCursor: Cursor = {
|
|
775
|
+
ancestors: cursor.ancestors.slice(0, a),
|
|
776
|
+
siblings: entry.siblings,
|
|
777
|
+
indexInSiblings: entry.indexInSiblings,
|
|
778
|
+
};
|
|
779
|
+
return checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode);
|
|
780
|
+
}
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
for (let a = cursor.ancestors.length - 1; a >= 0; a--) {
|
|
785
|
+
const entry = cursor.ancestors[a];
|
|
786
|
+
if (entry.kind !== ancestorKind) continue;
|
|
787
|
+
if (!matchesName(entry.name, segment)) continue;
|
|
788
|
+
if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) continue;
|
|
789
|
+
if (!checkDescendants(entry.node, segment.descendantChecks)) continue;
|
|
790
|
+
if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode)) continue;
|
|
791
|
+
const newCursor: Cursor = {
|
|
792
|
+
ancestors: cursor.ancestors.slice(0, a),
|
|
793
|
+
siblings: entry.siblings,
|
|
794
|
+
indexInSiblings: entry.indexInSiblings,
|
|
795
|
+
};
|
|
796
|
+
if (checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
|
|
797
|
+
}
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** True for nodes that a segment could ever match — used to skip text/whitespace when walking siblings. */
|
|
802
|
+
function isMatchableNode(node: BalanceNode): boolean {
|
|
803
|
+
return HTML_ELEMENT_TYPES.has(node.type)
|
|
804
|
+
|| node.type === 'mustache_section'
|
|
805
|
+
|| node.type === 'mustache_inverted_section'
|
|
806
|
+
|| node.type === 'mustache_interpolation'
|
|
807
|
+
|| node.type === 'mustache_triple'
|
|
808
|
+
|| node.type === 'mustache_comment'
|
|
809
|
+
|| node.type === 'mustache_partial';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function ancestorKindForSegment(segment: Segment): AncestorKind | null {
|
|
813
|
+
if (segment.rootOnly) return 'root';
|
|
814
|
+
if (segment.kind === 'html') return 'html';
|
|
815
|
+
if (segment.kind === 'section') return 'section';
|
|
816
|
+
if (segment.kind === 'inverted') return 'inverted';
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function getReportNode(node: BalanceNode, rootNode?: BalanceNode): BalanceNode {
|
|
821
|
+
if (HTML_ELEMENT_TYPES.has(node.type)) {
|
|
822
|
+
const startTag = node.children.find(
|
|
823
|
+
c => c.type === 'html_start_tag' || c.type === 'html_self_closing_tag',
|
|
824
|
+
);
|
|
825
|
+
return startTag ?? node;
|
|
826
|
+
}
|
|
827
|
+
if (node.type === 'mustache_section' || node.type === 'mustache_inverted_section') {
|
|
828
|
+
const begin = node.children.find(
|
|
829
|
+
c => c.type === 'mustache_section_begin' || c.type === 'mustache_inverted_section_begin',
|
|
830
|
+
);
|
|
831
|
+
return begin ?? node;
|
|
832
|
+
}
|
|
833
|
+
// When `:root` matches, the node covers the whole document. Narrow the
|
|
834
|
+
// reported range to a 1-char span at the document start so the diagnostic
|
|
835
|
+
// squiggle isn't the entire file.
|
|
836
|
+
if (rootNode && node === rootNode) {
|
|
837
|
+
return {
|
|
838
|
+
type: node.type,
|
|
839
|
+
text: '',
|
|
840
|
+
startPosition: node.startPosition,
|
|
841
|
+
endPosition: { row: node.startPosition.row, column: node.startPosition.column + 1 },
|
|
842
|
+
startIndex: node.startIndex,
|
|
843
|
+
endIndex: Math.min(node.startIndex + 1, node.endIndex),
|
|
844
|
+
children: [],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return node;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function matchAlternative(
|
|
851
|
+
rootNode: BalanceNode,
|
|
852
|
+
segments: Segment[],
|
|
853
|
+
rootSiblings: BalanceNode[],
|
|
854
|
+
rootIndexInSiblings: number,
|
|
855
|
+
): BalanceNode[] {
|
|
856
|
+
const results: BalanceNode[] = [];
|
|
857
|
+
const lastSegment = segments[segments.length - 1];
|
|
858
|
+
|
|
859
|
+
function walk(
|
|
860
|
+
node: BalanceNode,
|
|
861
|
+
ancestors: AncestorEntry[],
|
|
862
|
+
siblings: BalanceNode[],
|
|
863
|
+
indexInSiblings: number,
|
|
864
|
+
) {
|
|
865
|
+
if (nodeMatchesSegment(node, lastSegment, rootNode)) {
|
|
866
|
+
const cursor: Cursor = { ancestors, siblings, indexInSiblings };
|
|
867
|
+
if (
|
|
868
|
+
segments.length === 1 ||
|
|
869
|
+
checkPrefix(cursor, segments, segments.length - 2, lastSegment.combinator, rootNode)
|
|
870
|
+
) {
|
|
871
|
+
results.push(getReportNode(node, rootNode));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
let newAncestors = ancestors;
|
|
876
|
+
const ancestorKind = ancestorKindForNode(node);
|
|
877
|
+
if (ancestorKind !== null) {
|
|
878
|
+
const name =
|
|
879
|
+
ancestorKind === 'html' ? getTagName(node)?.toLowerCase() :
|
|
880
|
+
getSectionName(node)?.toLowerCase();
|
|
881
|
+
if (name) {
|
|
882
|
+
newAncestors = [...ancestors, { kind: ancestorKind, name, node, siblings, indexInSiblings }];
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
887
|
+
walk(node.children[i], newAncestors, node.children, i);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Seed the ancestor stack with a root entry so `:root X` / `:root > X`
|
|
892
|
+
// can find the document root as an ancestor. The root node itself is
|
|
893
|
+
// never an html/section/inverted node, so it's otherwise never pushed.
|
|
894
|
+
const rootEntry: AncestorEntry = {
|
|
895
|
+
kind: 'root', name: '', node: rootNode,
|
|
896
|
+
siblings: rootSiblings, indexInSiblings: rootIndexInSiblings,
|
|
897
|
+
};
|
|
898
|
+
walk(rootNode, [rootEntry], rootSiblings, rootIndexInSiblings);
|
|
899
|
+
return results;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export function matchSelector(
|
|
903
|
+
rootNode: BalanceNode,
|
|
904
|
+
selector: ParsedSelector,
|
|
905
|
+
siblings: BalanceNode[] = [],
|
|
906
|
+
indexInSiblings = 0,
|
|
907
|
+
): BalanceNode[] {
|
|
908
|
+
const allResults: BalanceNode[] = [];
|
|
909
|
+
const seen = new Set<BalanceNode>();
|
|
910
|
+
for (const alt of selector) {
|
|
911
|
+
for (const node of matchAlternative(rootNode, alt, siblings, indexInSiblings)) {
|
|
912
|
+
if (!seen.has(node)) {
|
|
913
|
+
seen.add(node);
|
|
914
|
+
allResults.push(node);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return allResults;
|
|
919
|
+
}
|