@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.
- package/README.md +49 -33
- 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 +168 -122
- 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,1272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatters - convert AST nodes to Doc IR.
|
|
3
|
+
*
|
|
4
|
+
* This module converts tree-sitter AST nodes to the Doc intermediate
|
|
5
|
+
* representation. It uses CSS display-based classification to determine
|
|
6
|
+
* whitespace sensitivity and wraps elements in groups so the printer
|
|
7
|
+
* can decide flat vs break based on print width.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
|
11
|
+
import type { TextDocument } from 'vscode-languageserver-textdocument';
|
|
12
|
+
import {
|
|
13
|
+
Doc,
|
|
14
|
+
concat,
|
|
15
|
+
fill,
|
|
16
|
+
hardline,
|
|
17
|
+
softline,
|
|
18
|
+
line,
|
|
19
|
+
indent,
|
|
20
|
+
indentN,
|
|
21
|
+
group,
|
|
22
|
+
text,
|
|
23
|
+
empty,
|
|
24
|
+
ifBreak,
|
|
25
|
+
isLine,
|
|
26
|
+
} from './ir.js';
|
|
27
|
+
import {
|
|
28
|
+
isBlockLevel,
|
|
29
|
+
shouldPreserveContent,
|
|
30
|
+
hasImplicitEndTags,
|
|
31
|
+
isInTextFlow,
|
|
32
|
+
shouldTreatAsBlock,
|
|
33
|
+
getCSSDisplay,
|
|
34
|
+
isWhitespaceInsensitive,
|
|
35
|
+
} from './classifier.js';
|
|
36
|
+
import { normalizeText, getVisibleChildren, normalizeMustacheWhitespace, normalizeMustacheWhitespaceAll, getIgnoreDirective, getTagName } from './utils.js';
|
|
37
|
+
import type { CustomCodeTagConfig } from '../customCodeTags.js';
|
|
38
|
+
import { getAttributeValue } from '../customCodeTags.js';
|
|
39
|
+
import { isRawContentElement } from '../nodeHelpers.js';
|
|
40
|
+
import type { NoBreakDelimiter } from '../configSchema.js';
|
|
41
|
+
|
|
42
|
+
export interface FormatterContext {
|
|
43
|
+
document: TextDocument;
|
|
44
|
+
customTags?: Map<string, CustomCodeTagConfig>;
|
|
45
|
+
embeddedFormatted?: Map<number, string>;
|
|
46
|
+
mustacheSpaces?: boolean;
|
|
47
|
+
noBreakDelimiters?: NoBreakDelimiter[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if an attribute value is truthy (not null, empty, "false", or "0").
|
|
52
|
+
*/
|
|
53
|
+
export function isAttributeTruthy(value: string | null): boolean {
|
|
54
|
+
if (value === null || value === '' || value === 'false' || value === '0') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Dedent content by stripping leading/trailing empty lines and removing the
|
|
62
|
+
* minimum common indentation from all non-empty lines.
|
|
63
|
+
*/
|
|
64
|
+
export function dedentContent(rawContent: string): string {
|
|
65
|
+
const lines = rawContent.split('\n');
|
|
66
|
+
|
|
67
|
+
// Strip leading empty lines
|
|
68
|
+
while (lines.length > 0 && lines[0].trim() === '') {
|
|
69
|
+
lines.shift();
|
|
70
|
+
}
|
|
71
|
+
// Strip trailing empty lines
|
|
72
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
73
|
+
lines.pop();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (lines.length === 0) return '';
|
|
77
|
+
|
|
78
|
+
// Find minimum indentation across non-empty lines
|
|
79
|
+
let minIndent = Infinity;
|
|
80
|
+
for (const l of lines) {
|
|
81
|
+
if (l.trim() === '') continue;
|
|
82
|
+
const match = l.match(/^(\s*)/);
|
|
83
|
+
if (match && match[1].length < minIndent) {
|
|
84
|
+
minIndent = match[1].length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (minIndent === Infinity) minIndent = 0;
|
|
88
|
+
|
|
89
|
+
// Strip common indent
|
|
90
|
+
return lines.map(l => l.trim() === '' ? '' : l.slice(minIndent)).join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve whether a custom code tag's content should be indented.
|
|
95
|
+
*/
|
|
96
|
+
function resolveIndentMode(
|
|
97
|
+
node: SyntaxNode,
|
|
98
|
+
config: CustomCodeTagConfig
|
|
99
|
+
): boolean {
|
|
100
|
+
const mode = config.indent ?? 'never';
|
|
101
|
+
if (mode === 'never') return false;
|
|
102
|
+
if (mode === 'always') return true;
|
|
103
|
+
// mode === 'attribute'
|
|
104
|
+
if (!config.indentAttribute) return false;
|
|
105
|
+
const value = getAttributeValue(node, config.indentAttribute);
|
|
106
|
+
return isAttributeTruthy(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getTagNameFromStartTag(startTag: SyntaxNode): string | null {
|
|
110
|
+
for (let i = 0; i < startTag.childCount; i++) {
|
|
111
|
+
const child = startTag.child(i);
|
|
112
|
+
if (child?.type === 'html_tag_name') return child.text.toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mustacheText(raw: string, context: FormatterContext): string {
|
|
118
|
+
if (context.mustacheSpaces !== undefined) {
|
|
119
|
+
return normalizeMustacheWhitespace(raw, context.mustacheSpaces);
|
|
120
|
+
}
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format the document root node.
|
|
126
|
+
*/
|
|
127
|
+
export function formatDocument(node: SyntaxNode, context: FormatterContext): Doc {
|
|
128
|
+
const children = getVisibleChildren(node);
|
|
129
|
+
const content = formatBlockChildren(children, context);
|
|
130
|
+
return concat([content, hardline]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format a node based on its type.
|
|
135
|
+
* @param forceInline - If true, format as inline even if content would normally be block-level
|
|
136
|
+
*/
|
|
137
|
+
export function formatNode(
|
|
138
|
+
node: SyntaxNode,
|
|
139
|
+
context: FormatterContext,
|
|
140
|
+
forceInline = false
|
|
141
|
+
): Doc {
|
|
142
|
+
const type = node.type;
|
|
143
|
+
|
|
144
|
+
switch (type) {
|
|
145
|
+
case 'document':
|
|
146
|
+
return formatDocument(node, context);
|
|
147
|
+
|
|
148
|
+
case 'html_element':
|
|
149
|
+
return formatHtmlElement(node, context, forceInline);
|
|
150
|
+
|
|
151
|
+
case 'html_script_element':
|
|
152
|
+
case 'html_style_element':
|
|
153
|
+
case 'html_raw_element':
|
|
154
|
+
return formatScriptStyleElement(node, context);
|
|
155
|
+
|
|
156
|
+
case 'mustache_section':
|
|
157
|
+
case 'mustache_inverted_section':
|
|
158
|
+
if (forceInline) {
|
|
159
|
+
if (context.mustacheSpaces !== undefined) {
|
|
160
|
+
return text(normalizeMustacheWhitespaceAll(node.text, context.mustacheSpaces));
|
|
161
|
+
}
|
|
162
|
+
return text(node.text);
|
|
163
|
+
}
|
|
164
|
+
return formatMustacheSection(node, context);
|
|
165
|
+
|
|
166
|
+
case 'mustache_interpolation':
|
|
167
|
+
case 'mustache_triple':
|
|
168
|
+
case 'mustache_partial':
|
|
169
|
+
case 'mustache_comment':
|
|
170
|
+
return text(mustacheText(node.text, context));
|
|
171
|
+
|
|
172
|
+
case 'html_comment':
|
|
173
|
+
case 'html_doctype':
|
|
174
|
+
case 'html_entity':
|
|
175
|
+
case 'html_erroneous_end_tag':
|
|
176
|
+
return text(node.text);
|
|
177
|
+
|
|
178
|
+
case 'text':
|
|
179
|
+
return formatText(node);
|
|
180
|
+
|
|
181
|
+
default:
|
|
182
|
+
return text(node.text);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format a text node.
|
|
188
|
+
*/
|
|
189
|
+
export function formatText(node: SyntaxNode): Doc {
|
|
190
|
+
return text(normalizeText(node.text));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Format an HTML element.
|
|
195
|
+
*/
|
|
196
|
+
export function formatHtmlElement(node: SyntaxNode, context: FormatterContext, forceInline = false): Doc {
|
|
197
|
+
const tags = context.customTags;
|
|
198
|
+
const display = getCSSDisplay(node, tags);
|
|
199
|
+
const isBlock = isWhitespaceInsensitive(display);
|
|
200
|
+
const preserveContent = shouldPreserveContent(node, tags);
|
|
201
|
+
|
|
202
|
+
// Self-closing tag
|
|
203
|
+
const selfClosing =
|
|
204
|
+
node.childCount === 1 && node.child(0)?.type === 'html_self_closing_tag';
|
|
205
|
+
|
|
206
|
+
if (selfClosing) {
|
|
207
|
+
const tag = node.child(0)!;
|
|
208
|
+
return formatStartTag(tag, context);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Get start tag, children, and end tag
|
|
212
|
+
let startTag: SyntaxNode | null = null;
|
|
213
|
+
let endTag: SyntaxNode | null = null;
|
|
214
|
+
let hasRealEndTag = false;
|
|
215
|
+
const contentNodes: SyntaxNode[] = [];
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
218
|
+
const child = node.child(i);
|
|
219
|
+
if (!child) continue;
|
|
220
|
+
|
|
221
|
+
if (child.type === 'html_start_tag') {
|
|
222
|
+
startTag = child;
|
|
223
|
+
} else if (child.type === 'html_end_tag') {
|
|
224
|
+
endTag = child;
|
|
225
|
+
hasRealEndTag = true;
|
|
226
|
+
} else if (child.type === 'html_forced_end_tag') {
|
|
227
|
+
endTag = child;
|
|
228
|
+
} else if (!child.type.startsWith('_')) {
|
|
229
|
+
contentNodes.push(child);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parts: Doc[] = [];
|
|
234
|
+
|
|
235
|
+
// Format start tag
|
|
236
|
+
if (startTag) {
|
|
237
|
+
parts.push(formatStartTag(startTag, context));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check if content contains any HTML element children
|
|
241
|
+
const hasHtmlElementChildren = contentNodes.some(
|
|
242
|
+
(child) =>
|
|
243
|
+
child.type === 'html_element' ||
|
|
244
|
+
isRawContentElement(child) ||
|
|
245
|
+
isBlockLevel(child, tags)
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Handle content
|
|
249
|
+
if (preserveContent) {
|
|
250
|
+
// Check if this custom code tag should be indented
|
|
251
|
+
const tagNameLower = startTag ? getTagNameFromStartTag(startTag) : null;
|
|
252
|
+
const tagConfig = tagNameLower ? context.customTags?.get(tagNameLower) : undefined;
|
|
253
|
+
const shouldIndent = tagConfig ? resolveIndentMode(node, tagConfig) : false;
|
|
254
|
+
|
|
255
|
+
if (shouldIndent && startTag && endTag) {
|
|
256
|
+
const rawContent = context.document.getText().slice(
|
|
257
|
+
startTag.endIndex,
|
|
258
|
+
endTag.startIndex
|
|
259
|
+
);
|
|
260
|
+
const dedented = dedentContent(rawContent);
|
|
261
|
+
if (dedented.length > 0) {
|
|
262
|
+
const contentLines = dedented.split('\n');
|
|
263
|
+
const lineDocs: Doc[] = [];
|
|
264
|
+
for (let j = 0; j < contentLines.length; j++) {
|
|
265
|
+
if (j > 0) {
|
|
266
|
+
if (contentLines[j] === '') {
|
|
267
|
+
// Empty line: literal \n avoids indentation from the printer
|
|
268
|
+
lineDocs.push('\n');
|
|
269
|
+
} else {
|
|
270
|
+
lineDocs.push(hardline);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (contentLines[j] !== '') {
|
|
274
|
+
lineDocs.push(text(contentLines[j]));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
parts.push(indent(concat([hardline, ...lineDocs])));
|
|
278
|
+
parts.push(hardline);
|
|
279
|
+
}
|
|
280
|
+
} else if (startTag && endTag) {
|
|
281
|
+
// Use raw document text to preserve all whitespace, since tree-sitter
|
|
282
|
+
// text nodes strip boundary whitespace from regular html_element children
|
|
283
|
+
const rawContent = context.document.getText().slice(
|
|
284
|
+
startTag.endIndex,
|
|
285
|
+
endTag.startIndex
|
|
286
|
+
);
|
|
287
|
+
// For block-level elements, replace trailing newline+whitespace with
|
|
288
|
+
// a hardline so the closing tag gets proper indentation from the printer
|
|
289
|
+
const trailingMatch = isBlock ? rawContent.match(/\n[\t ]*$/) : null;
|
|
290
|
+
if (trailingMatch) {
|
|
291
|
+
parts.push(text(rawContent.slice(0, -trailingMatch[0].length)));
|
|
292
|
+
parts.push(hardline);
|
|
293
|
+
} else {
|
|
294
|
+
parts.push(text(rawContent));
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
for (const child of contentNodes) {
|
|
298
|
+
parts.push(text(child.text));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} else if (!isBlock && (!hasHtmlElementChildren || (forceInline && display !== 'inline-block' && !contentNodes.some(
|
|
302
|
+
(child) => isRawContentElement(child) || isBlockLevel(child, tags)
|
|
303
|
+
)))) {
|
|
304
|
+
// Standalone element with attributes: use outer group wrapping so content
|
|
305
|
+
// goes on its own line when attributes wrap (matches Prettier's printTag)
|
|
306
|
+
if (!forceInline && startTag && startTagHasAttributes(startTag)) {
|
|
307
|
+
const formattedContent = formatBlockChildren(contentNodes, context);
|
|
308
|
+
if (hasDocContent(formattedContent)) {
|
|
309
|
+
const bareStartTag = formatStartTag(startTag, context, true);
|
|
310
|
+
const outerParts: Doc[] = [
|
|
311
|
+
group(bareStartTag),
|
|
312
|
+
indent(concat([softline, formattedContent])),
|
|
313
|
+
];
|
|
314
|
+
if (hasRealEndTag) {
|
|
315
|
+
outerParts.push(softline);
|
|
316
|
+
}
|
|
317
|
+
if (endTag) {
|
|
318
|
+
outerParts.push(formatEndTag(endTag));
|
|
319
|
+
}
|
|
320
|
+
return group(concat(outerParts));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Inline element with only text/interpolation content - keep tight
|
|
325
|
+
// Preserve whitespace gaps between sibling nodes (e.g. space between
|
|
326
|
+
// mustache_interpolation and text that tree-sitter puts in the gap)
|
|
327
|
+
let prevEnd = startTag ? startTag.endIndex : -1;
|
|
328
|
+
for (const child of contentNodes) {
|
|
329
|
+
if (prevEnd >= 0 && child.startIndex > prevEnd) {
|
|
330
|
+
const gap = context.document.getText().slice(prevEnd, child.startIndex);
|
|
331
|
+
if (/\s/.test(gap)) {
|
|
332
|
+
parts.push(text(' '));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
parts.push(formatNode(child, context, forceInline));
|
|
336
|
+
prevEnd = child.endIndex;
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
// Block element or inline-with-block-children: use hardline + indent
|
|
340
|
+
const formattedContent = formatBlockChildren(contentNodes, context);
|
|
341
|
+
const hasContent = hasDocContent(formattedContent);
|
|
342
|
+
|
|
343
|
+
if (hasContent) {
|
|
344
|
+
// Check if content has CSS-block children that would be treated as block
|
|
345
|
+
// by formatBlockChildren. Nodes in text flow (e.g. mustache sections
|
|
346
|
+
// adjacent to text) are inline regardless of their content.
|
|
347
|
+
const hasBlockChildren = contentNodes.some((child, i) => {
|
|
348
|
+
if (!shouldTreatAsBlock(child, i, contentNodes, tags)) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const childDisplay = getCSSDisplay(child, tags);
|
|
352
|
+
return isWhitespaceInsensitive(childDisplay) || isRawContentElement(child);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (isBlock && !hasBlockChildren) {
|
|
356
|
+
// Block element with only inline content: wrap in group so short ones stay flat
|
|
357
|
+
// e.g. <div>x</div> stays on one line, <div>long content...</div> breaks
|
|
358
|
+
const hasAttrs = startTag && startTagHasAttributes(startTag);
|
|
359
|
+
|
|
360
|
+
if (hasAttrs && startTag) {
|
|
361
|
+
// Outer group wrapping: match Prettier's printTag pattern
|
|
362
|
+
// group([group(openTag), indent([softline, content]), softline, closingTag])
|
|
363
|
+
const bareStartTag = formatStartTag(startTag, context, true);
|
|
364
|
+
const outerParts: Doc[] = [
|
|
365
|
+
group(bareStartTag),
|
|
366
|
+
indent(concat([softline, formattedContent])),
|
|
367
|
+
];
|
|
368
|
+
if (hasRealEndTag) {
|
|
369
|
+
outerParts.push(softline);
|
|
370
|
+
}
|
|
371
|
+
if (endTag) {
|
|
372
|
+
outerParts.push(formatEndTag(endTag));
|
|
373
|
+
}
|
|
374
|
+
return group(concat(outerParts));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// No attributes — existing logic
|
|
378
|
+
const doc = group(
|
|
379
|
+
concat([
|
|
380
|
+
indent(concat([softline, formattedContent])),
|
|
381
|
+
softline,
|
|
382
|
+
])
|
|
383
|
+
);
|
|
384
|
+
parts.push(doc);
|
|
385
|
+
// If no real end tag, don't add closing softline
|
|
386
|
+
if (!hasRealEndTag && endTag) {
|
|
387
|
+
// Remove the trailing softline we just added — content goes
|
|
388
|
+
// right up to forced end
|
|
389
|
+
parts.pop();
|
|
390
|
+
parts.push(
|
|
391
|
+
group(
|
|
392
|
+
concat([
|
|
393
|
+
indent(concat([softline, formattedContent])),
|
|
394
|
+
])
|
|
395
|
+
)
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
// Has block children: always break
|
|
400
|
+
parts.push(indent(concat([hardline, formattedContent])));
|
|
401
|
+
if (hasRealEndTag) {
|
|
402
|
+
parts.push(hardline);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} else if (contentNodes.length === 0 && hasRealEndTag) {
|
|
406
|
+
// Empty block element: <div>\n</div>
|
|
407
|
+
parts.push(hardline);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Format end tag
|
|
412
|
+
if (endTag) {
|
|
413
|
+
parts.push(formatEndTag(endTag));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return concat(parts);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Format script or style element.
|
|
421
|
+
* Uses pre-formatted content from embeddedFormatted map when available,
|
|
422
|
+
* otherwise preserves raw content as-is.
|
|
423
|
+
*/
|
|
424
|
+
export function formatScriptStyleElement(
|
|
425
|
+
node: SyntaxNode,
|
|
426
|
+
context: FormatterContext
|
|
427
|
+
): Doc {
|
|
428
|
+
const parts: Doc[] = [];
|
|
429
|
+
|
|
430
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
431
|
+
const child = node.child(i);
|
|
432
|
+
if (!child) continue;
|
|
433
|
+
|
|
434
|
+
if (child.type === 'html_start_tag') {
|
|
435
|
+
parts.push(formatStartTag(child, context));
|
|
436
|
+
} else if (child.type === 'html_end_tag') {
|
|
437
|
+
parts.push(formatEndTag(child));
|
|
438
|
+
} else if (child.type === 'html_raw_text') {
|
|
439
|
+
const formatted = context.embeddedFormatted?.get(child.startIndex);
|
|
440
|
+
if (formatted !== undefined) {
|
|
441
|
+
const trimmed = formatted.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
442
|
+
if (trimmed.length === 0) {
|
|
443
|
+
// Empty content — no lines between tags
|
|
444
|
+
} else {
|
|
445
|
+
const lines = trimmed.split('\n');
|
|
446
|
+
const lineDocs: Doc[] = [];
|
|
447
|
+
for (let j = 0; j < lines.length; j++) {
|
|
448
|
+
if (j > 0) {
|
|
449
|
+
lineDocs.push(hardline);
|
|
450
|
+
}
|
|
451
|
+
lineDocs.push(text(lines[j]));
|
|
452
|
+
}
|
|
453
|
+
parts.push(indent(concat([hardline, ...lineDocs])));
|
|
454
|
+
parts.push(hardline);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
// Fallback: preserve raw content as-is (also used for html_raw_element)
|
|
458
|
+
// Check if this is a custom code tag that should be indented
|
|
459
|
+
if (node.type === 'html_raw_element') {
|
|
460
|
+
const startTagNode = node.child(0);
|
|
461
|
+
const tagNameLower = startTagNode?.type === 'html_start_tag' ? getTagNameFromStartTag(startTagNode) : null;
|
|
462
|
+
const tagConfig = tagNameLower ? context.customTags?.get(tagNameLower) : undefined;
|
|
463
|
+
if (tagConfig && resolveIndentMode(node, tagConfig)) {
|
|
464
|
+
const dedented = dedentContent(child.text);
|
|
465
|
+
if (dedented.length > 0) {
|
|
466
|
+
const contentLines = dedented.split('\n');
|
|
467
|
+
const lineDocs: Doc[] = [];
|
|
468
|
+
for (let j = 0; j < contentLines.length; j++) {
|
|
469
|
+
if (j > 0) {
|
|
470
|
+
if (contentLines[j] === '') {
|
|
471
|
+
lineDocs.push('\n');
|
|
472
|
+
} else {
|
|
473
|
+
lineDocs.push(hardline);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (contentLines[j] !== '') {
|
|
477
|
+
lineDocs.push(text(contentLines[j]));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
parts.push(indent(concat([hardline, ...lineDocs])));
|
|
481
|
+
parts.push(hardline);
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
parts.push(text(child.text));
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
// Script/style fallback: dedent and re-emit with hardlines so the
|
|
488
|
+
// printer can apply proper indentation from parent context.
|
|
489
|
+
const dedented = dedentContent(child.text);
|
|
490
|
+
if (dedented.length > 0) {
|
|
491
|
+
const contentLines = dedented.split('\n');
|
|
492
|
+
const lineDocs: Doc[] = [];
|
|
493
|
+
for (let j = 0; j < contentLines.length; j++) {
|
|
494
|
+
if (j > 0) {
|
|
495
|
+
if (contentLines[j] === '') {
|
|
496
|
+
lineDocs.push('\n');
|
|
497
|
+
} else {
|
|
498
|
+
lineDocs.push(hardline);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (contentLines[j] !== '') {
|
|
502
|
+
lineDocs.push(text(contentLines[j]));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
parts.push(indent(concat([hardline, ...lineDocs])));
|
|
506
|
+
parts.push(hardline);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return concat(parts);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Format a mustache section ({{#...}} or {{^...}}).
|
|
518
|
+
*/
|
|
519
|
+
export function formatMustacheSection(
|
|
520
|
+
node: SyntaxNode,
|
|
521
|
+
context: FormatterContext
|
|
522
|
+
): Doc {
|
|
523
|
+
const isInverted = node.type === 'mustache_inverted_section';
|
|
524
|
+
const beginType = isInverted
|
|
525
|
+
? 'mustache_inverted_section_begin'
|
|
526
|
+
: 'mustache_section_begin';
|
|
527
|
+
const endType = isInverted
|
|
528
|
+
? 'mustache_inverted_section_end'
|
|
529
|
+
: 'mustache_section_end';
|
|
530
|
+
|
|
531
|
+
let beginNode: SyntaxNode | null = null;
|
|
532
|
+
let endNode: SyntaxNode | null = null;
|
|
533
|
+
const contentNodes: SyntaxNode[] = [];
|
|
534
|
+
|
|
535
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
536
|
+
const child = node.child(i);
|
|
537
|
+
if (!child) continue;
|
|
538
|
+
|
|
539
|
+
if (child.type === beginType) {
|
|
540
|
+
beginNode = child;
|
|
541
|
+
} else if (
|
|
542
|
+
child.type === endType ||
|
|
543
|
+
child.type === 'mustache_erroneous_section_end' ||
|
|
544
|
+
child.type === 'mustache_erroneous_inverted_section_end'
|
|
545
|
+
) {
|
|
546
|
+
endNode = child;
|
|
547
|
+
} else if (!child.type.startsWith('_')) {
|
|
548
|
+
contentNodes.push(child);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const parts: Doc[] = [];
|
|
553
|
+
|
|
554
|
+
// Opening tag
|
|
555
|
+
if (beginNode) {
|
|
556
|
+
parts.push(text(mustacheText(beginNode.text, context)));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Determine indentation: if content has implicit end tags (HTML crossing mustache
|
|
560
|
+
// boundaries), don't indent. Otherwise, indent normally.
|
|
561
|
+
const hasImplicit = hasImplicitEndTags(contentNodes);
|
|
562
|
+
|
|
563
|
+
// Staircase indentation: when content includes erroneous end tags (closing tags
|
|
564
|
+
// from a cross-section split). Each erroneous end tag gets a descending indent
|
|
565
|
+
// level so the outermost closing tag aligns at indent 0 (matching its opening).
|
|
566
|
+
// Non-erroneous content between erroneous end tags is indented one level deeper
|
|
567
|
+
// than the surrounding erroneous tags (it's a child of that scope).
|
|
568
|
+
const erroneousCount = contentNodes.filter(n => n.type === 'html_erroneous_end_tag').length;
|
|
569
|
+
const hasStaircase = !hasImplicit && erroneousCount > 0;
|
|
570
|
+
|
|
571
|
+
if (hasStaircase) {
|
|
572
|
+
let virtualDepth = erroneousCount - 1;
|
|
573
|
+
const groupNodes: SyntaxNode[] = [];
|
|
574
|
+
let lastNodeEnd = -1;
|
|
575
|
+
let pendingBlankLine = false;
|
|
576
|
+
let groupBlankLine = false;
|
|
577
|
+
|
|
578
|
+
const emitGroup = () => {
|
|
579
|
+
if (groupNodes.length === 0) return;
|
|
580
|
+
const formatted = formatBlockChildren(groupNodes, context);
|
|
581
|
+
if (hasDocContent(formatted)) {
|
|
582
|
+
if (groupBlankLine) parts.push('\n');
|
|
583
|
+
const depth = Math.max(0, virtualDepth + 1);
|
|
584
|
+
parts.push(depth > 0
|
|
585
|
+
? indentN(concat([hardline, formatted]), depth)
|
|
586
|
+
: concat([hardline, formatted]));
|
|
587
|
+
}
|
|
588
|
+
groupNodes.length = 0;
|
|
589
|
+
groupBlankLine = false;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
for (const node of contentNodes) {
|
|
593
|
+
if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd) {
|
|
594
|
+
const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
|
|
595
|
+
if ((gap.match(/\n/g) || []).length >= 2) {
|
|
596
|
+
pendingBlankLine = true;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (node.type === 'html_erroneous_end_tag') {
|
|
601
|
+
emitGroup();
|
|
602
|
+
if (pendingBlankLine) parts.push('\n');
|
|
603
|
+
pendingBlankLine = false;
|
|
604
|
+
const formatted = formatNode(node, context);
|
|
605
|
+
const depth = Math.max(0, virtualDepth);
|
|
606
|
+
parts.push(depth > 0
|
|
607
|
+
? indentN(concat([hardline, formatted]), depth)
|
|
608
|
+
: concat([hardline, formatted]));
|
|
609
|
+
virtualDepth--;
|
|
610
|
+
} else {
|
|
611
|
+
if (groupNodes.length === 0) {
|
|
612
|
+
groupBlankLine = pendingBlankLine;
|
|
613
|
+
pendingBlankLine = false;
|
|
614
|
+
}
|
|
615
|
+
groupNodes.push(node);
|
|
616
|
+
}
|
|
617
|
+
lastNodeEnd = node.endIndex;
|
|
618
|
+
}
|
|
619
|
+
emitGroup();
|
|
620
|
+
parts.push(hardline);
|
|
621
|
+
} else {
|
|
622
|
+
const formattedContent = formatBlockChildren(contentNodes, context);
|
|
623
|
+
const hasContent = hasDocContent(formattedContent);
|
|
624
|
+
|
|
625
|
+
if (hasContent) {
|
|
626
|
+
if (hasImplicit) {
|
|
627
|
+
// No indent for content with implicit end tags
|
|
628
|
+
parts.push(hardline);
|
|
629
|
+
parts.push(formattedContent);
|
|
630
|
+
parts.push(hardline);
|
|
631
|
+
} else {
|
|
632
|
+
// Check if content has CSS-block children (accounting for text flow)
|
|
633
|
+
const hasBlockChildren = contentNodes.some((child, i) => {
|
|
634
|
+
if (!shouldTreatAsBlock(child, i, contentNodes, context.customTags)) {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
const childDisplay = getCSSDisplay(child, context.customTags);
|
|
638
|
+
return isWhitespaceInsensitive(childDisplay) || isRawContentElement(child);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
if (!hasBlockChildren) {
|
|
642
|
+
// Inline content only: use group so short sections stay flat
|
|
643
|
+
parts.push(indent(concat([softline, formattedContent])));
|
|
644
|
+
parts.push(softline);
|
|
645
|
+
} else {
|
|
646
|
+
// Block content: always break
|
|
647
|
+
parts.push(indent(concat([hardline, formattedContent])));
|
|
648
|
+
parts.push(hardline);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Closing tag
|
|
655
|
+
if (endNode) {
|
|
656
|
+
parts.push(text(mustacheText(endNode.text, context)));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Wrap in group so inline-only content can stay flat
|
|
660
|
+
return group(concat(parts));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Check if a start tag has any attributes.
|
|
665
|
+
*/
|
|
666
|
+
function startTagHasAttributes(startTag: SyntaxNode): boolean {
|
|
667
|
+
for (let i = 0; i < startTag.childCount; i++) {
|
|
668
|
+
const child = startTag.child(i);
|
|
669
|
+
if (!child) continue;
|
|
670
|
+
if (
|
|
671
|
+
child.type === 'html_attribute' ||
|
|
672
|
+
child.type === 'mustache_attribute' ||
|
|
673
|
+
child.type === 'mustache_interpolation' ||
|
|
674
|
+
child.type === 'mustache_triple'
|
|
675
|
+
) {
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Format a start tag with attributes.
|
|
684
|
+
* Wraps in a group so attributes break onto separate lines when
|
|
685
|
+
* the tag exceeds print width.
|
|
686
|
+
* When `bare` is true, returns the tag IR without the outer group wrapper.
|
|
687
|
+
*/
|
|
688
|
+
export function formatStartTag(node: SyntaxNode, context?: FormatterContext, bare = false): Doc {
|
|
689
|
+
let tagNameText = '';
|
|
690
|
+
const attrs: Doc[] = [];
|
|
691
|
+
|
|
692
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
693
|
+
const child = node.child(i);
|
|
694
|
+
if (!child) continue;
|
|
695
|
+
|
|
696
|
+
if (child.type === 'html_tag_name') {
|
|
697
|
+
tagNameText = child.text;
|
|
698
|
+
} else if (child.type === 'html_attribute') {
|
|
699
|
+
attrs.push(formatAttribute(child, context));
|
|
700
|
+
} else if (child.type === 'mustache_attribute') {
|
|
701
|
+
if (context?.mustacheSpaces !== undefined) {
|
|
702
|
+
attrs.push(text(normalizeMustacheWhitespaceAll(child.text, context.mustacheSpaces)));
|
|
703
|
+
} else {
|
|
704
|
+
attrs.push(text(child.text));
|
|
705
|
+
}
|
|
706
|
+
} else if (child.type === 'mustache_interpolation' || child.type === 'mustache_triple') {
|
|
707
|
+
attrs.push(text(context ? mustacheText(child.text, context) : child.text));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const isSelfClosing = node.type === 'html_self_closing_tag';
|
|
712
|
+
const closingBracket = isSelfClosing ? ' />' : '>';
|
|
713
|
+
|
|
714
|
+
if (attrs.length === 0) {
|
|
715
|
+
return text('<' + tagNameText + closingBracket);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Build attribute list with line separators
|
|
719
|
+
const attrParts: Doc[] = [];
|
|
720
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
721
|
+
if (i > 0) {
|
|
722
|
+
attrParts.push(line);
|
|
723
|
+
}
|
|
724
|
+
attrParts.push(attrs[i]);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// In break mode, self-closing /> has no leading space (aligns with <tagName)
|
|
728
|
+
const breakClosingBracket = isSelfClosing ? '/>' : '>';
|
|
729
|
+
|
|
730
|
+
// Wrap tag in group: flat puts attrs on one line, break wraps them
|
|
731
|
+
const inner = concat([
|
|
732
|
+
text('<'),
|
|
733
|
+
text(tagNameText),
|
|
734
|
+
indent(concat([line, concat(attrParts)])),
|
|
735
|
+
ifBreak(concat([hardline, text(breakClosingBracket)]), text(closingBracket)),
|
|
736
|
+
]);
|
|
737
|
+
return bare ? inner : group(inner);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Format an end tag.
|
|
742
|
+
*/
|
|
743
|
+
export function formatEndTag(node: SyntaxNode): Doc {
|
|
744
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
745
|
+
const child = node.child(i);
|
|
746
|
+
if (child && child.type === 'html_tag_name') {
|
|
747
|
+
return text('</' + child.text + '>');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return text(node.text);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Format an HTML attribute.
|
|
755
|
+
*/
|
|
756
|
+
export function formatAttribute(node: SyntaxNode, context?: FormatterContext): Doc {
|
|
757
|
+
const parts: Doc[] = [];
|
|
758
|
+
|
|
759
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
760
|
+
const child = node.child(i);
|
|
761
|
+
if (!child) continue;
|
|
762
|
+
|
|
763
|
+
if (child.type === 'html_attribute_name') {
|
|
764
|
+
parts.push(text(child.text));
|
|
765
|
+
} else if (child.type === 'html_attribute_value') {
|
|
766
|
+
parts.push(text('='));
|
|
767
|
+
parts.push(text(child.text));
|
|
768
|
+
} else if (child.type === 'html_quoted_attribute_value') {
|
|
769
|
+
parts.push(text('='));
|
|
770
|
+
if (context?.mustacheSpaces !== undefined) {
|
|
771
|
+
parts.push(text(normalizeMustacheWhitespaceAll(child.text, context.mustacheSpaces)));
|
|
772
|
+
} else {
|
|
773
|
+
parts.push(text(child.text));
|
|
774
|
+
}
|
|
775
|
+
} else if (child.type === 'mustache_interpolation') {
|
|
776
|
+
parts.push(text('='));
|
|
777
|
+
parts.push(text(context ? mustacheText(child.text, context) : child.text));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return concat(parts);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Split a single-line text string into alternating words and `line` separators.
|
|
786
|
+
* Returns an array of fill-ready parts to spread into `currentLine`.
|
|
787
|
+
*/
|
|
788
|
+
function textWords(str: string): Doc[] {
|
|
789
|
+
const words = str.split(/\s+/).filter((w) => w.length > 0);
|
|
790
|
+
if (words.length === 0) return [];
|
|
791
|
+
const parts: Doc[] = [words[0]];
|
|
792
|
+
for (let i = 1; i < words.length; i++) {
|
|
793
|
+
parts.push(line);
|
|
794
|
+
parts.push(words[i]);
|
|
795
|
+
}
|
|
796
|
+
return parts;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Replace `line` separators with `" "` inside delimited regions so the
|
|
801
|
+
* fill algorithm treats delimited content as unbreakable.
|
|
802
|
+
*
|
|
803
|
+
* Scans string parts for delimiter boundaries. Between an opening and closing
|
|
804
|
+
* delimiter, any `line` separator is replaced with a literal space string.
|
|
805
|
+
* Delimiters are matched longest-first to handle e.g. `$$` before `$`.
|
|
806
|
+
*/
|
|
807
|
+
export function collapseDelimitedRegions(parts: Doc[], delimiters: NoBreakDelimiter[]): Doc[] {
|
|
808
|
+
if (delimiters.length === 0) return parts;
|
|
809
|
+
|
|
810
|
+
// Sort longest-first by max(start.length, end.length) so $$ is checked before $
|
|
811
|
+
const sorted = [...delimiters].sort(
|
|
812
|
+
(a, b) => Math.max(b.start.length, b.end.length) - Math.max(a.start.length, a.end.length)
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
const result = [...parts];
|
|
816
|
+
let activeDelimiter: NoBreakDelimiter | null = null;
|
|
817
|
+
|
|
818
|
+
for (let i = 0; i < result.length; i++) {
|
|
819
|
+
const part = result[i];
|
|
820
|
+
|
|
821
|
+
if (typeof part === 'string') {
|
|
822
|
+
if (activeDelimiter === null) {
|
|
823
|
+
// Look for an opening delimiter
|
|
824
|
+
for (const delim of sorted) {
|
|
825
|
+
const startIdx = part.indexOf(delim.start);
|
|
826
|
+
if (startIdx >= 0) {
|
|
827
|
+
// Check if it also closes in the same string
|
|
828
|
+
const afterOpen = startIdx + delim.start.length;
|
|
829
|
+
const closeIdx = part.indexOf(delim.end, afterOpen);
|
|
830
|
+
if (closeIdx >= 0) {
|
|
831
|
+
// Self-contained (e.g. "$x$") — no state change, already atomic
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
activeDelimiter = delim;
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
// Look for the closing delimiter
|
|
840
|
+
if (part.includes(activeDelimiter.end)) {
|
|
841
|
+
activeDelimiter = null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
} else if (activeDelimiter !== null && isLine(part)) {
|
|
845
|
+
// Inside a delimited region: replace line with non-breaking space
|
|
846
|
+
result[i] = ' ';
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return result;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Convert inline content parts into a fill Doc that wraps at word boundaries.
|
|
855
|
+
*
|
|
856
|
+
* `currentLine` is already fill-ready: text nodes are pre-split into
|
|
857
|
+
* alternating word/`line` parts by `textWords`, and inter-node gaps are
|
|
858
|
+
* `line` separators. This function enforces proper alternating
|
|
859
|
+
* content/separator structure, concatenates adjacent content, and attaches
|
|
860
|
+
* leading punctuation to the preceding content.
|
|
861
|
+
*/
|
|
862
|
+
function inlineContentToFill(parts: Doc[]): Doc {
|
|
863
|
+
if (parts.length === 0) return empty;
|
|
864
|
+
if (parts.length === 1) return parts[0];
|
|
865
|
+
|
|
866
|
+
const fillParts: Doc[] = [];
|
|
867
|
+
for (const item of parts) {
|
|
868
|
+
if (isLine(item)) {
|
|
869
|
+
// Only push separator after content (skip leading/duplicate separators)
|
|
870
|
+
if (fillParts.length > 0 && !isLine(fillParts[fillParts.length - 1])) {
|
|
871
|
+
fillParts.push(item);
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
const lastIdx = fillParts.length - 1;
|
|
875
|
+
if (lastIdx >= 0 && !isLine(fillParts[lastIdx])) {
|
|
876
|
+
// Adjacent content (no separator) — concat with previous
|
|
877
|
+
fillParts[lastIdx] = concat([fillParts[lastIdx], item]);
|
|
878
|
+
} else if (
|
|
879
|
+
typeof item === 'string' &&
|
|
880
|
+
/^[,.:;!?)\]]/.test(item) &&
|
|
881
|
+
lastIdx >= 0 &&
|
|
882
|
+
isLine(fillParts[lastIdx])
|
|
883
|
+
) {
|
|
884
|
+
// Punctuation after separator — attach to preceding content
|
|
885
|
+
fillParts.pop();
|
|
886
|
+
if (fillParts.length > 0) {
|
|
887
|
+
fillParts[fillParts.length - 1] = concat([
|
|
888
|
+
fillParts[fillParts.length - 1],
|
|
889
|
+
item,
|
|
890
|
+
]);
|
|
891
|
+
} else {
|
|
892
|
+
fillParts.push(item);
|
|
893
|
+
}
|
|
894
|
+
} else {
|
|
895
|
+
fillParts.push(item);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Remove trailing separator
|
|
901
|
+
if (fillParts.length > 0 && isLine(fillParts[fillParts.length - 1])) {
|
|
902
|
+
fillParts.pop();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return fill(fillParts);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Format block-level children with display-aware separators.
|
|
910
|
+
*/
|
|
911
|
+
export function formatBlockChildren(
|
|
912
|
+
nodes: SyntaxNode[],
|
|
913
|
+
context: FormatterContext
|
|
914
|
+
): Doc {
|
|
915
|
+
const lines: { doc: Doc; blankLineBefore: boolean; rawLine?: boolean }[] = [];
|
|
916
|
+
let currentLine: Doc[] = [];
|
|
917
|
+
let lastNodeEnd = -1;
|
|
918
|
+
let pendingBlankLine = false;
|
|
919
|
+
let blankLineBeforeCurrentLine = false;
|
|
920
|
+
let ignoreNext = false;
|
|
921
|
+
let inIgnoreRegion = false;
|
|
922
|
+
let ignoreRegionStartIndex = -1;
|
|
923
|
+
|
|
924
|
+
const noBreakDelims = context.noBreakDelimiters;
|
|
925
|
+
function flushCurrentLine(): Doc {
|
|
926
|
+
const parts = noBreakDelims ? collapseDelimitedRegions(currentLine, noBreakDelims) : currentLine;
|
|
927
|
+
return inlineContentToFill(parts);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
931
|
+
const node = nodes[i];
|
|
932
|
+
|
|
933
|
+
// Detect blank lines in gap between nodes (before directive handling)
|
|
934
|
+
if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd && !inIgnoreRegion) {
|
|
935
|
+
const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
|
|
936
|
+
const newlineCount = (gap.match(/\n/g) || []).length;
|
|
937
|
+
if (newlineCount >= 2) {
|
|
938
|
+
pendingBlankLine = true;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const directive = getIgnoreDirective(node);
|
|
943
|
+
|
|
944
|
+
// --- Ignore directive handling ---
|
|
945
|
+
|
|
946
|
+
// ignore-end: close a region
|
|
947
|
+
if (directive === 'ignore-end' && inIgnoreRegion) {
|
|
948
|
+
// Flush any pending inline content
|
|
949
|
+
if (currentLine.length > 0) {
|
|
950
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
951
|
+
if (hasDocContent(lineContent)) {
|
|
952
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
953
|
+
}
|
|
954
|
+
currentLine = [];
|
|
955
|
+
blankLineBeforeCurrentLine = false;
|
|
956
|
+
}
|
|
957
|
+
// Emit raw text from region start to this comment, trimming boundary newlines
|
|
958
|
+
const rawText = context.document.getText().slice(ignoreRegionStartIndex, node.startIndex)
|
|
959
|
+
.replace(/^\n/, '').replace(/\n$/, '');
|
|
960
|
+
if (rawText.length > 0) {
|
|
961
|
+
lines.push({ doc: text(rawText), blankLineBefore: false, rawLine: true });
|
|
962
|
+
}
|
|
963
|
+
// Emit the ignore-end comment itself (rawLine to avoid adding indent after raw text)
|
|
964
|
+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
|
|
965
|
+
lines.push({ doc: text(commentText), blankLineBefore: false, rawLine: true });
|
|
966
|
+
inIgnoreRegion = false;
|
|
967
|
+
ignoreRegionStartIndex = -1;
|
|
968
|
+
lastNodeEnd = node.endIndex;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Inside ignore region: skip (content captured as raw text at ignore-end)
|
|
973
|
+
if (inIgnoreRegion) {
|
|
974
|
+
lastNodeEnd = node.endIndex;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ignore-start: begin a region
|
|
979
|
+
if (directive === 'ignore-start') {
|
|
980
|
+
if (currentLine.length > 0) {
|
|
981
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
982
|
+
if (hasDocContent(lineContent)) {
|
|
983
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
984
|
+
}
|
|
985
|
+
currentLine = [];
|
|
986
|
+
blankLineBeforeCurrentLine = false;
|
|
987
|
+
}
|
|
988
|
+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
|
|
989
|
+
lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
|
|
990
|
+
pendingBlankLine = false;
|
|
991
|
+
inIgnoreRegion = true;
|
|
992
|
+
ignoreRegionStartIndex = node.endIndex;
|
|
993
|
+
lastNodeEnd = node.endIndex;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ignore (next-node): emit the comment, set flag
|
|
998
|
+
if (directive === 'ignore') {
|
|
999
|
+
if (currentLine.length > 0) {
|
|
1000
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1001
|
+
if (hasDocContent(lineContent)) {
|
|
1002
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1003
|
+
}
|
|
1004
|
+
currentLine = [];
|
|
1005
|
+
blankLineBeforeCurrentLine = false;
|
|
1006
|
+
}
|
|
1007
|
+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
|
|
1008
|
+
lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
|
|
1009
|
+
pendingBlankLine = false;
|
|
1010
|
+
ignoreNext = true;
|
|
1011
|
+
lastNodeEnd = node.endIndex;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Ignored next-node: emit raw text, clear flag
|
|
1016
|
+
if (ignoreNext) {
|
|
1017
|
+
lines.push({ doc: text(node.text), blankLineBefore: pendingBlankLine });
|
|
1018
|
+
pendingBlankLine = false;
|
|
1019
|
+
ignoreNext = false;
|
|
1020
|
+
lastNodeEnd = node.endIndex;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ignore-end without ignore-start: treat as normal comment (fall through)
|
|
1025
|
+
|
|
1026
|
+
const treatAsBlock = shouldTreatAsBlock(node, i, nodes, context.customTags);
|
|
1027
|
+
|
|
1028
|
+
// Check for whitespace between nodes in original document (inline gap handling)
|
|
1029
|
+
if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd) {
|
|
1030
|
+
const prevNode = nodes[i - 1];
|
|
1031
|
+
const prevTreatAsBlock = shouldTreatAsBlock(prevNode, i - 1, nodes, context.customTags);
|
|
1032
|
+
|
|
1033
|
+
if (!prevTreatAsBlock && !treatAsBlock) {
|
|
1034
|
+
const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
|
|
1035
|
+
if (/\s/.test(gap)) {
|
|
1036
|
+
currentLine.push(line);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (treatAsBlock) {
|
|
1042
|
+
// Flush current inline content
|
|
1043
|
+
if (currentLine.length > 0) {
|
|
1044
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1045
|
+
if (hasDocContent(lineContent)) {
|
|
1046
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1047
|
+
}
|
|
1048
|
+
currentLine = [];
|
|
1049
|
+
blankLineBeforeCurrentLine = false;
|
|
1050
|
+
}
|
|
1051
|
+
// Add block element
|
|
1052
|
+
lines.push({ doc: formatNode(node, context), blankLineBefore: pendingBlankLine });
|
|
1053
|
+
pendingBlankLine = false;
|
|
1054
|
+
} else if (node.type === 'html_comment' || node.type === 'mustache_comment') {
|
|
1055
|
+
// Comments on their own line if multi-line or on their own line in source
|
|
1056
|
+
const isMultiline = node.startPosition.row !== node.endPosition.row;
|
|
1057
|
+
const isOnOwnLine = i > 0 && node.startPosition.row > nodes[i - 1].endPosition.row;
|
|
1058
|
+
if (isMultiline || isOnOwnLine) {
|
|
1059
|
+
if (currentLine.length > 0) {
|
|
1060
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1061
|
+
if (hasDocContent(lineContent)) {
|
|
1062
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1063
|
+
}
|
|
1064
|
+
currentLine = [];
|
|
1065
|
+
blankLineBeforeCurrentLine = false;
|
|
1066
|
+
}
|
|
1067
|
+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
|
|
1068
|
+
lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
|
|
1069
|
+
pendingBlankLine = false;
|
|
1070
|
+
} else {
|
|
1071
|
+
if (currentLine.length === 0) {
|
|
1072
|
+
blankLineBeforeCurrentLine = pendingBlankLine;
|
|
1073
|
+
pendingBlankLine = false;
|
|
1074
|
+
}
|
|
1075
|
+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
|
|
1076
|
+
currentLine.push(text(commentText));
|
|
1077
|
+
}
|
|
1078
|
+
} else {
|
|
1079
|
+
// Inline content
|
|
1080
|
+
if (currentLine.length === 0) {
|
|
1081
|
+
blankLineBeforeCurrentLine = pendingBlankLine;
|
|
1082
|
+
pendingBlankLine = false;
|
|
1083
|
+
}
|
|
1084
|
+
const forceInline = isInTextFlow(node, i, nodes);
|
|
1085
|
+
const formatted = formatNode(node, context, forceInline);
|
|
1086
|
+
|
|
1087
|
+
// Check if formatted content contains newlines (multi-line text)
|
|
1088
|
+
if (typeof formatted === 'string' && formatted.includes('\n')) {
|
|
1089
|
+
const contentLines = formatted.split('\n');
|
|
1090
|
+
const isTextNode = node.type === 'text';
|
|
1091
|
+
|
|
1092
|
+
if (isTextNode) {
|
|
1093
|
+
// Re-flow: treat source newlines as word boundaries, only flush at
|
|
1094
|
+
// blank lines. This lets the fill algorithm handle all wrapping.
|
|
1095
|
+
for (let j = 0; j < contentLines.length; j++) {
|
|
1096
|
+
const trimmed = contentLines[j].trim();
|
|
1097
|
+
if (!trimmed) {
|
|
1098
|
+
// Empty line = paragraph break — flush current inline flow
|
|
1099
|
+
if (currentLine.length > 0) {
|
|
1100
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1101
|
+
if (hasDocContent(lineContent)) {
|
|
1102
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1103
|
+
blankLineBeforeCurrentLine = false;
|
|
1104
|
+
}
|
|
1105
|
+
currentLine = [];
|
|
1106
|
+
}
|
|
1107
|
+
pendingBlankLine = true;
|
|
1108
|
+
} else {
|
|
1109
|
+
if (currentLine.length === 0) {
|
|
1110
|
+
blankLineBeforeCurrentLine = pendingBlankLine;
|
|
1111
|
+
pendingBlankLine = false;
|
|
1112
|
+
}
|
|
1113
|
+
// Add a line separator between joined source lines (j > 0),
|
|
1114
|
+
// but not before the first line — it continues the existing flow
|
|
1115
|
+
if (j > 0 && currentLine.length > 0) {
|
|
1116
|
+
currentLine.push(line);
|
|
1117
|
+
}
|
|
1118
|
+
currentLine.push(...textWords(trimmed));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
} else {
|
|
1122
|
+
// Non-text nodes (force-inline mustache sections, etc.):
|
|
1123
|
+
// preserve source newlines as hard line breaks.
|
|
1124
|
+
const firstTrimmed = contentLines[0].trim();
|
|
1125
|
+
if (firstTrimmed) {
|
|
1126
|
+
currentLine.push(firstTrimmed);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (currentLine.length > 0) {
|
|
1130
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1131
|
+
if (hasDocContent(lineContent)) {
|
|
1132
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1133
|
+
blankLineBeforeCurrentLine = pendingBlankLine;
|
|
1134
|
+
pendingBlankLine = false;
|
|
1135
|
+
}
|
|
1136
|
+
currentLine = [];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
let sawBlankLine = false;
|
|
1140
|
+
for (let j = 1; j < contentLines.length - 1; j++) {
|
|
1141
|
+
const trimmed = contentLines[j].trim();
|
|
1142
|
+
if (trimmed) {
|
|
1143
|
+
lines.push({ doc: text(trimmed), blankLineBefore: blankLineBeforeCurrentLine || sawBlankLine });
|
|
1144
|
+
blankLineBeforeCurrentLine = false;
|
|
1145
|
+
sawBlankLine = false;
|
|
1146
|
+
} else {
|
|
1147
|
+
sawBlankLine = true;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (contentLines.length > 1) {
|
|
1152
|
+
const lastTrimmed = contentLines[contentLines.length - 1].trim();
|
|
1153
|
+
if (lastTrimmed) {
|
|
1154
|
+
blankLineBeforeCurrentLine = sawBlankLine;
|
|
1155
|
+
sawBlankLine = false;
|
|
1156
|
+
currentLine = [lastTrimmed];
|
|
1157
|
+
}
|
|
1158
|
+
if (sawBlankLine) {
|
|
1159
|
+
pendingBlankLine = true;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
} else {
|
|
1164
|
+
// For text nodes, spread word/line parts directly into currentLine
|
|
1165
|
+
if (node.type === 'text' && typeof formatted === 'string') {
|
|
1166
|
+
const words = textWords(formatted);
|
|
1167
|
+
if (words.length > 0) {
|
|
1168
|
+
currentLine.push(...words);
|
|
1169
|
+
} else if (node.text.trim() === '' && currentLine.length > 0) {
|
|
1170
|
+
// Whitespace-only text between inline content: preserve as line separator
|
|
1171
|
+
currentLine.push(line);
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
currentLine.push(formatted);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Force line break after <br> tags
|
|
1180
|
+
if (node.type === 'html_element' && currentLine.length > 0) {
|
|
1181
|
+
const tagName = getTagName(node);
|
|
1182
|
+
if (tagName?.toLowerCase() === 'br') {
|
|
1183
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1184
|
+
if (hasDocContent(lineContent)) {
|
|
1185
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1186
|
+
blankLineBeforeCurrentLine = false;
|
|
1187
|
+
}
|
|
1188
|
+
currentLine = [];
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
lastNodeEnd = node.endIndex;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Handle unterminated ignore region: emit remaining raw text
|
|
1196
|
+
if (inIgnoreRegion && nodes.length > 0) {
|
|
1197
|
+
const lastNode = nodes[nodes.length - 1];
|
|
1198
|
+
const rawText = context.document.getText().slice(ignoreRegionStartIndex, lastNode.endIndex)
|
|
1199
|
+
.replace(/^\n/, '');
|
|
1200
|
+
if (rawText.length > 0) {
|
|
1201
|
+
lines.push({ doc: text(rawText), blankLineBefore: false, rawLine: true });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Flush remaining inline content
|
|
1206
|
+
if (currentLine.length > 0) {
|
|
1207
|
+
const lineContent = trimDoc(flushCurrentLine());
|
|
1208
|
+
if (hasDocContent(lineContent)) {
|
|
1209
|
+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Join lines with hardlines
|
|
1214
|
+
if (lines.length === 0) {
|
|
1215
|
+
return empty;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const parts: Doc[] = [];
|
|
1219
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1220
|
+
if (i > 0) {
|
|
1221
|
+
if (lines[i].blankLineBefore) {
|
|
1222
|
+
// Emit a blank line: literal \n (no indent) + hardline (with indent)
|
|
1223
|
+
parts.push('\n');
|
|
1224
|
+
}
|
|
1225
|
+
if (lines[i].rawLine) {
|
|
1226
|
+
// Raw lines (ignored regions): literal \n to avoid adding indentation
|
|
1227
|
+
parts.push('\n');
|
|
1228
|
+
} else {
|
|
1229
|
+
parts.push(hardline);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
parts.push(lines[i].doc);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return concat(parts);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Check if a Doc has any meaningful content.
|
|
1240
|
+
*/
|
|
1241
|
+
function hasDocContent(doc: Doc): boolean {
|
|
1242
|
+
if (typeof doc === 'string') {
|
|
1243
|
+
return doc.trim().length > 0;
|
|
1244
|
+
}
|
|
1245
|
+
if (doc.type === 'concat') {
|
|
1246
|
+
return doc.parts.some(hasDocContent);
|
|
1247
|
+
}
|
|
1248
|
+
if (doc.type === 'indent') {
|
|
1249
|
+
return hasDocContent(doc.contents);
|
|
1250
|
+
}
|
|
1251
|
+
if (doc.type === 'group') {
|
|
1252
|
+
return hasDocContent(doc.contents);
|
|
1253
|
+
}
|
|
1254
|
+
if (doc.type === 'fill') {
|
|
1255
|
+
return doc.parts.some(hasDocContent);
|
|
1256
|
+
}
|
|
1257
|
+
if (doc.type === 'ifBreak') {
|
|
1258
|
+
return hasDocContent(doc.breakContents) || hasDocContent(doc.flatContents);
|
|
1259
|
+
}
|
|
1260
|
+
// hardline, softline, line, breakParent are structural
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Trim whitespace from the beginning and end of a Doc string.
|
|
1266
|
+
*/
|
|
1267
|
+
function trimDoc(doc: Doc): Doc {
|
|
1268
|
+
if (typeof doc === 'string') {
|
|
1269
|
+
return doc.trim();
|
|
1270
|
+
}
|
|
1271
|
+
return doc;
|
|
1272
|
+
}
|