@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,207 @@
1
+ /**
2
+ * Smoke tests for the browser entry. Runs in Node via vitest; `locateWasm`
3
+ * returns absolute file paths (which `Language.load` / web-tree-sitter's
4
+ * Emscripten loader accept in Node contexts).
5
+ */
6
+
7
+ import { describe, it, expect, beforeAll } from 'vitest';
8
+ import * as path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { createLinter, DEFAULT_CONFIG, type Linter, type PrettierLike } from './index.js';
11
+ import { GRAMMAR_WASM_FILENAME } from '../core/grammar.js';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
15
+ const GRAMMAR_WASM_PATH = path.resolve(REPO_ROOT, GRAMMAR_WASM_FILENAME);
16
+
17
+ let linter: Linter;
18
+
19
+ beforeAll(async () => {
20
+ linter = await createLinter({
21
+ locateWasm: (name) => {
22
+ if (name === GRAMMAR_WASM_FILENAME) return GRAMMAR_WASM_PATH;
23
+ // Fall through for tree-sitter.wasm — web-tree-sitter resolves it from
24
+ // its own package in node_modules.
25
+ return path.resolve(REPO_ROOT, 'node_modules', 'web-tree-sitter', name);
26
+ },
27
+ });
28
+ });
29
+
30
+ describe('createLinter', () => {
31
+ it('is idempotent across multiple calls (returns working handles)', async () => {
32
+ const a = await createLinter({ locateWasm: GRAMMAR_WASM_PATH });
33
+ const b = await createLinter({ locateWasm: GRAMMAR_WASM_PATH });
34
+ expect(a.lint('<p></p>', DEFAULT_CONFIG)).toEqual([]);
35
+ expect(b.lint('<p></p>', DEFAULT_CONFIG)).toEqual([]);
36
+ });
37
+
38
+ it('accepts string locateWasm as grammar URL', async () => {
39
+ const h = await createLinter({ locateWasm: GRAMMAR_WASM_PATH });
40
+ expect(h.lint('<p></p>', DEFAULT_CONFIG)).toEqual([]);
41
+ });
42
+ });
43
+
44
+ describe('lint', () => {
45
+ it('returns [] for clean HTML', () => {
46
+ expect(linter.lint('<p>hi</p>', DEFAULT_CONFIG)).toEqual([]);
47
+ });
48
+
49
+ it('flags unquoted mustache attributes (built-in rule)', () => {
50
+ const d = linter.lint('<a href={{url}}></a>', DEFAULT_CONFIG);
51
+ expect(d.length).toBeGreaterThan(0);
52
+ const rule = d.find((x) => x.ruleName === 'unquotedMustacheAttributes');
53
+ expect(rule).toBeDefined();
54
+ expect(rule!.severity).toBe('error');
55
+ expect(rule!.line).toBe(1);
56
+ expect(rule!.column).toBeGreaterThan(0);
57
+ });
58
+
59
+ it('flags self-closing non-void tags with a fix', () => {
60
+ const d = linter.lint('<div/>', DEFAULT_CONFIG);
61
+ const rule = d.find((x) => x.ruleName === 'selfClosingNonVoidTags');
62
+ expect(rule).toBeDefined();
63
+ expect(rule!.fix).toBeDefined();
64
+ expect(rule!.fix!.length).toBeGreaterThanOrEqual(1);
65
+ const [start, end] = rule!.fix![0].range;
66
+ expect(typeof start).toBe('number');
67
+ expect(typeof end).toBe('number');
68
+ expect(end).toBeGreaterThan(start);
69
+ });
70
+
71
+ it('flags duplicate attributes', () => {
72
+ const d = linter.lint('<p id="a" id="b"></p>', DEFAULT_CONFIG);
73
+ expect(d.some((x) => x.ruleName === 'duplicateAttributes')).toBe(true);
74
+ });
75
+
76
+ it('flags nested duplicate sections', () => {
77
+ const d = linter.lint('{{#x}}{{#x}}hi{{/x}}{{/x}}', DEFAULT_CONFIG);
78
+ expect(d.some((x) => x.ruleName === 'nestedDuplicateSections')).toBe(true);
79
+ });
80
+
81
+ it('flags consecutive duplicate sections with a range-deletion fix', () => {
82
+ const d = linter.lint('{{#x}}a{{/x}}{{#x}}b{{/x}}', DEFAULT_CONFIG);
83
+ const rule = d.find((x) => x.ruleName === 'consecutiveDuplicateSections');
84
+ expect(rule).toBeDefined();
85
+ expect(rule!.fix).toBeDefined();
86
+ expect(Array.isArray(rule!.fix)).toBe(true);
87
+ expect(rule!.fix![0].newText).toBe('');
88
+ });
89
+
90
+ it('flags unrecognized HTML tags unless registered as customTag', () => {
91
+ const raw = linter.lint('<my-widget></my-widget>', {
92
+ rules: { unrecognizedHtmlTags: 'error' },
93
+ });
94
+ expect(raw.some((x) => x.ruleName === 'unrecognizedHtmlTags')).toBe(true);
95
+
96
+ const withCustom = linter.lint('<my-widget></my-widget>', {
97
+ rules: { unrecognizedHtmlTags: 'error' },
98
+ customTags: [{ name: 'my-widget' }],
99
+ });
100
+ expect(withCustom.some((x) => x.ruleName === 'unrecognizedHtmlTags')).toBe(false);
101
+ });
102
+
103
+ it('matches a custom selector-based rule', () => {
104
+ const d = linter.lint('<script>x</script>', {
105
+ customRules: [{ id: 'no-script', selector: 'script', message: 'Bare <script> is disallowed' }],
106
+ });
107
+ expect(d.some((x) => x.ruleName === 'no-script')).toBe(true);
108
+ });
109
+
110
+ it('honors <!-- htmlmustache-disable ruleName --> directives', () => {
111
+ const src = '<!-- htmlmustache-disable duplicateAttributes -->\n<p id="a" id="b"></p>';
112
+ const d = linter.lint(src, DEFAULT_CONFIG);
113
+ expect(d.some((x) => x.ruleName === 'duplicateAttributes')).toBe(false);
114
+ });
115
+
116
+ it('honors {{! htmlmustache-disable ruleName }} directives', () => {
117
+ const src = '{{! htmlmustache-disable duplicateAttributes }}\n<p id="a" id="b"></p>';
118
+ const d = linter.lint(src, DEFAULT_CONFIG);
119
+ expect(d.some((x) => x.ruleName === 'duplicateAttributes')).toBe(false);
120
+ });
121
+
122
+ it('is pure (same input → same output)', () => {
123
+ const src = '<p id="a" id="b"></p>';
124
+ const a = linter.lint(src, DEFAULT_CONFIG);
125
+ const b = linter.lint(src, DEFAULT_CONFIG);
126
+ expect(b).toEqual(a);
127
+ });
128
+
129
+ it('survives 500 iterations without throwing (rough memory / GC sanity)', () => {
130
+ const src = '<div><p>{{name}}</p><span class="x">{{value}}</span></div>';
131
+ for (let i = 0; i < 500; i++) linter.lint(src, DEFAULT_CONFIG);
132
+ // If web-tree-sitter was leaking a tree per iteration, this would OOM the
133
+ // native heap. Reaching here without throwing is the signal we want.
134
+ expect(true).toBe(true);
135
+ });
136
+
137
+ it('reports parse errors without a ruleName', () => {
138
+ const d = linter.lint('<div', DEFAULT_CONFIG);
139
+ const parseErr = d.find((x) => !x.ruleName);
140
+ expect(parseErr).toBeDefined();
141
+ expect(parseErr!.severity).toBe('error');
142
+ });
143
+ });
144
+
145
+ describe('format', () => {
146
+ it('formats nested blocks', async () => {
147
+ const out = await linter.format('<div><p>hi</p></div>');
148
+ expect(out).toBe('<div>\n <p>hi</p>\n</div>\n');
149
+ });
150
+
151
+ it('is idempotent', async () => {
152
+ const src = '<div><p>{{name}}</p></div>';
153
+ const once = await linter.format(src);
154
+ const twice = await linter.format(once);
155
+ expect(twice).toBe(once);
156
+ });
157
+
158
+ it('respects printWidth + indentSize from config', async () => {
159
+ const out = await linter.format('<div><p>hi</p></div>', { indentSize: 4 });
160
+ expect(out).toBe('<div>\n <p>hi</p>\n</div>\n');
161
+ });
162
+
163
+ it('applies mustacheSpaces from config', async () => {
164
+ const out = await linter.format('<p>{{name}}</p>', { mustacheSpaces: true });
165
+ expect(out).toBe('<p>{{ name }}</p>\n');
166
+ });
167
+
168
+ it('leaves <script> body untouched when no prettier provided', async () => {
169
+ const src = '<script>var a=1 ;</script>';
170
+ const out = await linter.format(src);
171
+ expect(out).toContain('var a=1 ;');
172
+ });
173
+
174
+ it('formats <script> body via injected prettier (per-call)', async () => {
175
+ const prettier: PrettierLike = {
176
+ format: (src, opts) => {
177
+ expect(opts.parser).toBe('babel');
178
+ return `/* PRETTIER-${src.trim()} */\n`;
179
+ },
180
+ };
181
+ const out = await linter.format('<script>var a=1;</script>', undefined, { prettier });
182
+ expect(out).toContain('/* PRETTIER-var a=1; */');
183
+ });
184
+
185
+ it('uses factory-level prettier by default', async () => {
186
+ const prettier: PrettierLike = {
187
+ format: () => 'FACTORY_OUT\n',
188
+ };
189
+ const h = await createLinter({ locateWasm: GRAMMAR_WASM_PATH, prettier });
190
+ const out = await h.format('<script>var a=1;</script>');
191
+ expect(out).toContain('FACTORY_OUT');
192
+ });
193
+ });
194
+
195
+ describe('DEFAULT_CONFIG', () => {
196
+ it('has rule entries for every built-in rule', () => {
197
+ const rules = DEFAULT_CONFIG.rules as Record<string, string>;
198
+ expect(rules.nestedDuplicateSections).toBeDefined();
199
+ expect(rules.unquotedMustacheAttributes).toBeDefined();
200
+ expect(rules.consecutiveDuplicateSections).toBeDefined();
201
+ expect(rules.selfClosingNonVoidTags).toBeDefined();
202
+ expect(rules.duplicateAttributes).toBeDefined();
203
+ expect(rules.unescapedEntities).toBeDefined();
204
+ expect(rules.preferMustacheComments).toBeDefined();
205
+ expect(rules.unrecognizedHtmlTags).toBeDefined();
206
+ });
207
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Browser entry point for `@reteps/tree-sitter-htmlmustache`.
3
+ *
4
+ * Exposes `createLinter({ locateWasm, prettier? })` returning a handle with
5
+ * `lint(source, config)` and `format(source, config, opts)`. Internally reuses
6
+ * the shared rule engine and formatter in `src/core/` — browser-safe, no fs.
7
+ */
8
+
9
+ import { Parser, Language } from 'web-tree-sitter';
10
+ import { TextDocument } from 'vscode-languageserver-textdocument';
11
+
12
+ import { collectErrors } from '../core/collectErrors.js';
13
+ import type { WalkableTree } from '../core/collectErrors.js';
14
+ import { formatDocument } from '../core/formatting/index.js';
15
+ import type { FormattingOptions } from '../core/formatting/index.js';
16
+ import { mergeOptions } from '../core/formatting/mergeOptions.js';
17
+ import { formatEmbeddedRegions } from '../core/formatting/embedded.js';
18
+ import type { PrettierLike } from '../core/formatting/embedded.js';
19
+ import { RULE_DEFAULTS } from '../core/ruleMetadata.js';
20
+ import { toDiagnostic } from '../core/diagnostic.js';
21
+ import type { Diagnostic } from '../core/diagnostic.js';
22
+ import { GRAMMAR_WASM_FILENAME } from '../core/grammar.js';
23
+ import type {
24
+ HtmlMustacheConfig,
25
+ RulesConfig,
26
+ RuleSeverity,
27
+ CustomRule as CustomRuleType,
28
+ } from '../core/configSchema.js';
29
+ import type { CustomCodeTagConfig } from '../core/customCodeTags.js';
30
+
31
+ export type Config = Omit<HtmlMustacheConfig, 'include' | 'exclude'>;
32
+ export type CustomRule = CustomRuleType;
33
+ export type CustomTag = CustomCodeTagConfig;
34
+ export type { RulesConfig, RuleSeverity, PrettierLike, Diagnostic };
35
+
36
+ export type LocateWasm = string | ((filename: string) => string);
37
+
38
+ export interface CreateLinterOptions {
39
+ /**
40
+ * Locates the grammar WASM (`tree-sitter-htmlmustache.wasm`). If a string,
41
+ * treated as the URL for the grammar — web-tree-sitter's own
42
+ * `tree-sitter.wasm` will resolve via its default `locateFile`. Pass a
43
+ * callback to resolve both names explicitly.
44
+ */
45
+ locateWasm: LocateWasm;
46
+ /** Default prettier used for embedded-region formatting. */
47
+ prettier?: PrettierLike;
48
+ }
49
+
50
+ export interface FormatOptions {
51
+ /** Override the factory-level prettier for this call. */
52
+ prettier?: PrettierLike;
53
+ }
54
+
55
+ export interface Linter {
56
+ lint(source: string, config?: Config): Diagnostic[];
57
+ format(source: string, config?: Config, opts?: FormatOptions): Promise<string>;
58
+ }
59
+
60
+ /** Default severities for every built-in rule. */
61
+ export const DEFAULT_CONFIG: Config = { rules: RULE_DEFAULTS as RulesConfig };
62
+
63
+ const DEFAULT_FORMATTING_OPTIONS: FormattingOptions = { tabSize: 2, insertSpaces: true };
64
+
65
+ function toLocateFile(locateWasm: LocateWasm): ((name: string) => string) | undefined {
66
+ // String form resolves only the grammar; runtime `tree-sitter.wasm` falls
67
+ // through to web-tree-sitter's own default `locateFile`.
68
+ return typeof locateWasm === 'function' ? (name) => locateWasm(name) : undefined;
69
+ }
70
+
71
+ function resolveGrammarUrl(locateWasm: LocateWasm): string {
72
+ return typeof locateWasm === 'string' ? locateWasm : locateWasm(GRAMMAR_WASM_FILENAME);
73
+ }
74
+
75
+ /**
76
+ * Create a linter/formatter handle. Consumers should cache the result — each
77
+ * call reloads the grammar WASM.
78
+ */
79
+ export async function createLinter(opts: CreateLinterOptions): Promise<Linter> {
80
+ const { locateWasm, prettier: factoryPrettier } = opts;
81
+ const locateFile = toLocateFile(locateWasm);
82
+ // `Parser.init` is itself idempotent (Emscripten caches the runtime globally),
83
+ // so repeated calls with different `locateFile` are safe — the first wins.
84
+ await Parser.init(locateFile ? { locateFile } : undefined);
85
+ const parser = new Parser();
86
+ const language = await Language.load(resolveGrammarUrl(locateWasm));
87
+ parser.setLanguage(language);
88
+
89
+ return {
90
+ lint(source, config) {
91
+ const tree = parser.parse(source);
92
+ if (!tree) throw new Error('Failed to parse document');
93
+ try {
94
+ const customTagNames = config?.customTags?.map((t) => t.name);
95
+ const errors = collectErrors(
96
+ tree as unknown as WalkableTree,
97
+ config?.rules,
98
+ customTagNames,
99
+ config?.customRules,
100
+ );
101
+ return errors.map(toDiagnostic);
102
+ } finally {
103
+ tree.delete();
104
+ }
105
+ },
106
+
107
+ async format(source, config, callOpts) {
108
+ const prettier = callOpts?.prettier ?? factoryPrettier;
109
+ const options = mergeOptions(DEFAULT_FORMATTING_OPTIONS, config ?? null);
110
+ const tree = parser.parse(source);
111
+ if (!tree) throw new Error('Failed to parse document');
112
+ try {
113
+ const embeddedFormatted = await formatEmbeddedRegions(tree.rootNode, options, prettier);
114
+ const document = TextDocument.create('file:///input', 'htmlmustache', 1, source);
115
+ const edits = formatDocument(tree, document, options, {
116
+ customTags: config?.customTags,
117
+ printWidth: config?.printWidth,
118
+ mustacheSpaces: config?.mustacheSpaces,
119
+ noBreakDelimiters: config?.noBreakDelimiters,
120
+ embeddedFormatted: embeddedFormatted.size > 0 ? embeddedFormatted : undefined,
121
+ });
122
+ return edits.length === 0 ? source : edits[0].newText;
123
+ } finally {
124
+ tree.delete();
125
+ }
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "emitDeclarationOnly": true,
14
+ "outDir": "../../browser/out"
15
+ },
16
+ "include": ["index.ts", "../core/**/*.ts"],
17
+ "exclude": ["../core/**/*.test.ts"]
18
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Shared error collection logic used by both the LSP diagnostics
3
+ * and the CLI linter.
4
+ */
5
+
6
+ import type { BalanceNode } from './htmlBalanceChecker.js';
7
+ import { checkHtmlBalance, checkUnclosedTags } from './htmlBalanceChecker.js';
8
+ import {
9
+ checkNestedSameNameSections,
10
+ checkUnquotedMustacheAttributes,
11
+ checkConsecutiveSameNameSections,
12
+ checkSelfClosingNonVoidTags,
13
+ checkDuplicateAttributes,
14
+ checkUnescapedEntities,
15
+ checkHtmlComments,
16
+ checkUnrecognizedHtmlTags,
17
+ checkElementContentTooLong,
18
+ } from './mustacheChecks.js';
19
+ import type { TextReplacement } from './mustacheChecks.js';
20
+ import type { RulesConfig, RuleSeverity, CustomRule, ElementContentTooLongOptions } from './configSchema.js';
21
+ import { RULE_DEFAULTS, KNOWN_RULE_NAMES } from './ruleMetadata.js';
22
+ import { parseSelector, matchSelector } from './selectorMatcher.js';
23
+ import type { ParsedSelector } from './selectorMatcher.js';
24
+
25
+ // Parsing a selector is non-trivial (runs parsel-js) and lint() is called
26
+ // per-keystroke in browsers, so cache by raw selector string. `null` cached
27
+ // when the selector is unparseable to avoid retrying.
28
+ const selectorCache = new Map<string, ParsedSelector | null>();
29
+
30
+ function parseSelectorCached(raw: string): ParsedSelector | null {
31
+ const hit = selectorCache.get(raw);
32
+ if (hit !== undefined) return hit;
33
+ const parsed = parseSelector(raw);
34
+ selectorCache.set(raw, parsed);
35
+ return parsed;
36
+ }
37
+
38
+ /** A tree that provides walk() and rootNode, compatible with both web-tree-sitter and CLI wasm. */
39
+ export interface WalkableTree {
40
+ walk(): TreeCursor;
41
+ rootNode: BalanceNode;
42
+ }
43
+
44
+ interface TreeCursor {
45
+ currentNode: BalanceNode;
46
+ nodeType: string;
47
+ nodeIsMissing: boolean;
48
+ gotoFirstChild(): boolean;
49
+ gotoNextSibling(): boolean;
50
+ gotoParent(): boolean;
51
+ }
52
+
53
+ /** Unified error result from all checkers. */
54
+ export interface CheckError {
55
+ node: BalanceNode;
56
+ message: string;
57
+ severity?: 'error' | 'warning';
58
+ fix?: TextReplacement[];
59
+ fixDescription?: string;
60
+ ruleName?: string;
61
+ }
62
+
63
+ const ERROR_NODE_TYPES = new Set([
64
+ 'ERROR',
65
+ 'mustache_erroneous_section_end',
66
+ 'mustache_erroneous_inverted_section_end',
67
+ ]);
68
+
69
+ function errorMessageForNode(nodeType: string, node: BalanceNode): string {
70
+ if (nodeType === 'mustache_erroneous_section_end' || nodeType === 'mustache_erroneous_inverted_section_end') {
71
+ const tagNameNode = node.children.find(c => c.type === 'mustache_erroneous_tag_name');
72
+ return `Mismatched mustache section: {{/${tagNameNode?.text || '?'}}}`;
73
+ }
74
+ if (nodeType === 'ERROR') {
75
+ return 'Syntax error';
76
+ }
77
+ // isMissing node
78
+ return `Missing ${nodeType}`;
79
+ }
80
+
81
+ function resolveRuleConfig<K extends keyof RulesConfig>(
82
+ rules: RulesConfig | undefined,
83
+ ruleName: K,
84
+ ): { severity: RuleSeverity; entry: RulesConfig[K] } {
85
+ const entry = rules?.[ruleName];
86
+ let severity: RuleSeverity;
87
+ if (entry === undefined) {
88
+ severity = RULE_DEFAULTS[ruleName] ?? 'off';
89
+ } else if (typeof entry === 'string') {
90
+ severity = entry;
91
+ } else {
92
+ severity = (entry as { severity: RuleSeverity }).severity;
93
+ }
94
+ return { severity, entry: entry as RulesConfig[K] };
95
+ }
96
+
97
+ function parseDisableDirective(node: BalanceNode, customRuleIds?: Set<string>): string | null {
98
+ if (node.type !== 'html_comment' && node.type !== 'mustache_comment') return null;
99
+ let inner: string | null = null;
100
+ if (node.type === 'html_comment') {
101
+ const match = node.text.match(/^<!--([\s\S]*)-->$/);
102
+ if (match) inner = match[1].trim();
103
+ } else {
104
+ const match = node.text.match(/^\{\{!([\s\S]*)\}\}$/);
105
+ if (match) inner = match[1].trim();
106
+ }
107
+ if (!inner) return null;
108
+ const prefix = 'htmlmustache-disable ';
109
+ if (!inner.startsWith(prefix)) return null;
110
+ const ruleName = inner.slice(prefix.length).trim();
111
+ if (KNOWN_RULE_NAMES.has(ruleName)) return ruleName;
112
+ if (customRuleIds?.has(ruleName)) return ruleName;
113
+ return null;
114
+ }
115
+
116
+ function collectDisabledRules(rootNode: BalanceNode, customRuleIds?: Set<string>): Set<string> {
117
+ const disabled = new Set<string>();
118
+ function walk(node: BalanceNode) {
119
+ const rule = parseDisableDirective(node, customRuleIds);
120
+ if (rule) { disabled.add(rule); return; }
121
+ for (const child of node.children) walk(child);
122
+ }
123
+ walk(rootNode);
124
+ return disabled;
125
+ }
126
+
127
+ /**
128
+ * Collect all errors from a parsed tree: syntax errors, balance errors,
129
+ * unclosed tags, and mustache lint checks.
130
+ */
131
+ export function collectErrors(tree: WalkableTree, rules?: RulesConfig, customTagNames?: string[], customRules?: CustomRule[]): CheckError[] {
132
+ const errors: CheckError[] = [];
133
+ const cursor = tree.walk() as unknown as TreeCursor;
134
+
135
+ function visit() {
136
+ const node = cursor.currentNode;
137
+ const nodeType = cursor.nodeType;
138
+
139
+ if (ERROR_NODE_TYPES.has(nodeType) || cursor.nodeIsMissing) {
140
+ errors.push({
141
+ node,
142
+ message: errorMessageForNode(nodeType, node),
143
+ });
144
+
145
+ // Don't recurse into ERROR nodes — the children are not meaningful
146
+ if (nodeType === 'ERROR') return;
147
+ }
148
+
149
+ if (cursor.gotoFirstChild()) {
150
+ do { visit(); } while (cursor.gotoNextSibling());
151
+ cursor.gotoParent();
152
+ }
153
+ }
154
+
155
+ visit();
156
+
157
+ // Run balance checker for HTML tag mismatch detection across mustache paths
158
+ const balanceErrors = checkHtmlBalance(tree.rootNode);
159
+ for (const error of balanceErrors) {
160
+ errors.push({ node: error.node, message: error.message });
161
+ }
162
+
163
+ // Check for unclosed non-void HTML tags
164
+ const unclosedErrors = checkUnclosedTags(tree.rootNode);
165
+ for (const error of unclosedErrors) {
166
+ errors.push({ node: error.node, message: error.message });
167
+ }
168
+
169
+ // Collect inline disable directives and merge into effective rules
170
+ const customRuleIds = customRules ? new Set(customRules.map(r => r.id)) : undefined;
171
+ const disabledRules = collectDisabledRules(tree.rootNode, customRuleIds);
172
+ const effectiveRules = { ...rules };
173
+ for (const rule of disabledRules) {
174
+ (effectiveRules as Record<string, string>)[rule] = 'off';
175
+ }
176
+
177
+ // Configurable lint checks
178
+ const sourceText = tree.rootNode.text;
179
+
180
+ const ruleChecks: { rule: keyof RulesConfig; errors: (entry: RulesConfig[keyof RulesConfig]) => import('./mustacheChecks.js').FixableError[] }[] = [
181
+ { rule: 'nestedDuplicateSections', errors: () => checkNestedSameNameSections(tree.rootNode) },
182
+ { rule: 'unquotedMustacheAttributes', errors: () => checkUnquotedMustacheAttributes(tree.rootNode) },
183
+ { rule: 'consecutiveDuplicateSections', errors: () => checkConsecutiveSameNameSections(tree.rootNode, sourceText) },
184
+ { rule: 'selfClosingNonVoidTags', errors: () => checkSelfClosingNonVoidTags(tree.rootNode) },
185
+ { rule: 'duplicateAttributes', errors: () => checkDuplicateAttributes(tree.rootNode) },
186
+ { rule: 'unescapedEntities', errors: () => checkUnescapedEntities(tree.rootNode) },
187
+ { rule: 'preferMustacheComments', errors: () => checkHtmlComments(tree.rootNode) },
188
+ { rule: 'unrecognizedHtmlTags', errors: () => checkUnrecognizedHtmlTags(tree.rootNode, customTagNames) },
189
+ {
190
+ rule: 'elementContentTooLong',
191
+ errors: (entry) => {
192
+ const elements = (entry && typeof entry === 'object' ? (entry as ElementContentTooLongOptions).elements : undefined) ?? [];
193
+ return checkElementContentTooLong(tree.rootNode, elements);
194
+ },
195
+ },
196
+ ];
197
+
198
+ for (const { rule, errors: getErrors } of ruleChecks) {
199
+ const { severity, entry } = resolveRuleConfig(effectiveRules, rule);
200
+ if (severity === 'off') continue;
201
+
202
+ for (const error of getErrors(entry)) {
203
+ errors.push({
204
+ node: error.node,
205
+ message: error.message,
206
+ severity,
207
+ fix: error.fix,
208
+ fixDescription: error.fixDescription,
209
+ ruleName: rule,
210
+ });
211
+ }
212
+ }
213
+
214
+ // Custom selector-based rules
215
+ if (customRules) {
216
+ for (const rule of customRules) {
217
+ if (disabledRules.has(rule.id)) continue;
218
+ const severity = rule.severity ?? 'error';
219
+ if (severity === 'off') continue;
220
+ const parsed = parseSelectorCached(rule.selector);
221
+ if (!parsed) continue;
222
+ const matches = matchSelector(tree.rootNode, parsed);
223
+ for (const node of matches) {
224
+ errors.push({ node, message: rule.message, severity, ruleName: rule.id });
225
+ }
226
+ }
227
+ }
228
+
229
+ // Filter out preferMustacheComments warnings on disable-directive comments themselves
230
+ return errors.filter(e =>
231
+ !(e.message.includes('HTML comment found') && parseDisableDirective(e.node, customRuleIds) !== null)
232
+ );
233
+ }