@reteps/tree-sitter-htmlmustache 0.8.1 → 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.
- 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 +3612 -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 +74 -0
- package/browser/out/core/selectorMatcher.d.ts.map +1 -0
- package/cli/out/main.js +133 -115
- 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 +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
|
+
}
|