@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.
Files changed (67) hide show
  1. package/README.md +1 -1
  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 +133 -115
  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,185 @@
1
+ /**
2
+ * IR-Based Formatter for HTML with Mustache templates.
3
+ *
4
+ * Architecture: AST Node → Doc IR → String
5
+ *
6
+ * Three phases:
7
+ * 1. Classification - Determine node type (block/inline/preserve)
8
+ * 2. AST → IR - Convert nodes to formatting commands
9
+ * 3. IR → String - Print with proper indentation
10
+ */
11
+
12
+ import type { Node as SyntaxNode, Tree } from 'web-tree-sitter';
13
+ import type { TextDocument } from 'vscode-languageserver-textdocument';
14
+
15
+ /** Formatting options (structurally compatible with LSP FormattingOptions). */
16
+ export interface FormattingOptions {
17
+ tabSize: number;
18
+ insertSpaces: boolean;
19
+ }
20
+
21
+ /** A position in a text document (0-based line and character). */
22
+ export interface Position {
23
+ line: number;
24
+ character: number;
25
+ }
26
+
27
+ /** A range in a text document. */
28
+ export interface Range {
29
+ start: Position;
30
+ end: Position;
31
+ }
32
+
33
+ /** A text edit to apply to a document. */
34
+ export interface TextEdit {
35
+ range: Range;
36
+ newText: string;
37
+ }
38
+
39
+ import { print } from './printer.js';
40
+ import { formatDocument as formatDocumentToDoc, FormatterContext } from './formatters.js';
41
+ import { createIndentUnit } from './mergeOptions.js';
42
+ import type { NoBreakDelimiter } from '../configSchema.js';
43
+ import { findContainingNode, calculateIndentLevel } from './utils.js';
44
+ import { isBlockLevel, getContentNodes, hasImplicitEndTags } from './classifier.js';
45
+ import type { CustomCodeTagConfig } from '../customCodeTags.js';
46
+
47
+ export interface FormatDocumentParams {
48
+ customTags?: CustomCodeTagConfig[];
49
+ printWidth?: number;
50
+ embeddedFormatted?: Map<number, string>;
51
+ mustacheSpaces?: boolean;
52
+ noBreakDelimiters?: NoBreakDelimiter[];
53
+ }
54
+
55
+ function buildCustomTagMap(customTags?: CustomCodeTagConfig[]): Map<string, CustomCodeTagConfig> | undefined {
56
+ if (!customTags || customTags.length === 0) return undefined;
57
+ const map = new Map<string, CustomCodeTagConfig>();
58
+ for (const config of customTags) {
59
+ map.set(config.name.toLowerCase(), config);
60
+ }
61
+ return map;
62
+ }
63
+
64
+ export function formatDocument(
65
+ tree: Tree,
66
+ document: TextDocument,
67
+ options: FormattingOptions,
68
+ params: FormatDocumentParams = {},
69
+ ): TextEdit[] {
70
+ const { printWidth = 80, embeddedFormatted, mustacheSpaces, noBreakDelimiters } = params;
71
+ const indentUnit = createIndentUnit(options);
72
+
73
+ if (tree.rootNode.hasError) return [];
74
+
75
+ const customTagMap = buildCustomTagMap(params.customTags);
76
+ const context: FormatterContext = {
77
+ document,
78
+ customTags: customTagMap,
79
+ embeddedFormatted,
80
+ mustacheSpaces,
81
+ noBreakDelimiters,
82
+ };
83
+ const doc = formatDocumentToDoc(tree.rootNode, context);
84
+ const formatted = print(doc, { indentUnit, printWidth });
85
+
86
+ const fullRange: Range = {
87
+ start: { line: 0, character: 0 },
88
+ end: document.positionAt(document.getText().length),
89
+ };
90
+
91
+ return [{ range: fullRange, newText: formatted }];
92
+ }
93
+
94
+ export function formatDocumentRange(
95
+ tree: Tree,
96
+ document: TextDocument,
97
+ range: Range,
98
+ options: FormattingOptions,
99
+ params: FormatDocumentParams = {},
100
+ ): TextEdit[] {
101
+ const { customTags, printWidth = 80, embeddedFormatted, mustacheSpaces, noBreakDelimiters } = params;
102
+ const indentUnit = createIndentUnit(options);
103
+
104
+ if (tree.rootNode.hasError) return [];
105
+
106
+ const customTagMap = buildCustomTagMap(customTags);
107
+
108
+ const startOffset = document.offsetAt(range.start);
109
+ const endOffset = document.offsetAt(range.end);
110
+
111
+ let targetNode = findContainingNode(tree.rootNode, startOffset, endOffset) ?? tree.rootNode;
112
+
113
+ // Walk up to the nearest block-level boundary so the output is anchored to a
114
+ // whole element, not mid-inline content.
115
+ while (
116
+ targetNode.parent &&
117
+ !isBlockLevel(targetNode, customTagMap) &&
118
+ targetNode.type !== 'document'
119
+ ) {
120
+ targetNode = targetNode.parent;
121
+ }
122
+
123
+ const indentLevel = calculateIndentLevel(
124
+ targetNode,
125
+ isBlockLevel,
126
+ hasImplicitEndTags,
127
+ getContentNodes
128
+ );
129
+
130
+ const context: FormatterContext = {
131
+ document,
132
+ customTags: customTagMap,
133
+ embeddedFormatted,
134
+ mustacheSpaces,
135
+ noBreakDelimiters,
136
+ };
137
+ const doc = formatNodeForRange(targetNode, context);
138
+ const formatted = print(doc, { indentUnit, printWidth });
139
+
140
+ const indentedFormatted = applyBaseIndent(formatted, indentLevel, indentUnit);
141
+
142
+ const nodeRange: Range = {
143
+ start: {
144
+ line: targetNode.startPosition.row,
145
+ character: targetNode.startPosition.column,
146
+ },
147
+ end: {
148
+ line: targetNode.endPosition.row,
149
+ character: targetNode.endPosition.column,
150
+ },
151
+ };
152
+
153
+ return [{ range: nodeRange, newText: indentedFormatted }];
154
+ }
155
+
156
+ import { formatNode } from './formatters.js';
157
+
158
+ function formatNodeForRange(
159
+ node: SyntaxNode,
160
+ context: FormatterContext
161
+ ): import('./ir.js').Doc {
162
+ return formatNode(node, context);
163
+ }
164
+
165
+ function applyBaseIndent(
166
+ formatted: string,
167
+ indentLevel: number,
168
+ indentUnit: string
169
+ ): string {
170
+ if (indentLevel === 0) {
171
+ return formatted;
172
+ }
173
+
174
+ const baseIndent = indentUnit.repeat(indentLevel);
175
+ return formatted
176
+ .split('\n')
177
+ .map((line, index) => {
178
+ // Don't indent empty lines or the first line (it's at the node position)
179
+ if (line.trim() === '' || index === 0) {
180
+ return line;
181
+ }
182
+ return baseIndent + line;
183
+ })
184
+ .join('\n');
185
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Intermediate Representation (IR) for document formatting.
3
+ *
4
+ * This module defines the Doc type and builder functions for creating
5
+ * formatting commands. The IR is inspired by Prettier's document model.
6
+ */
7
+
8
+ // Doc types - the IR for formatting
9
+ export type Doc =
10
+ | string // literal text
11
+ | Concat
12
+ | Indent
13
+ | Hardline
14
+ | Softline
15
+ | Line
16
+ | Group
17
+ | Fill
18
+ | BreakParent
19
+ | IfBreak;
20
+
21
+ export interface Concat {
22
+ type: 'concat';
23
+ parts: Doc[];
24
+ }
25
+
26
+ export interface Indent {
27
+ type: 'indent';
28
+ contents: Doc;
29
+ }
30
+
31
+ export interface Hardline {
32
+ type: 'hardline';
33
+ }
34
+
35
+ export interface Softline {
36
+ type: 'softline';
37
+ }
38
+
39
+ export interface Line {
40
+ type: 'line';
41
+ }
42
+
43
+ export interface Group {
44
+ type: 'group';
45
+ contents: Doc;
46
+ break?: boolean;
47
+ id?: symbol;
48
+ }
49
+
50
+ export interface Fill {
51
+ type: 'fill';
52
+ parts: Doc[];
53
+ }
54
+
55
+ export interface BreakParent {
56
+ type: 'breakParent';
57
+ }
58
+
59
+ export interface IfBreak {
60
+ type: 'ifBreak';
61
+ breakContents: Doc;
62
+ flatContents: Doc;
63
+ groupId?: symbol;
64
+ }
65
+
66
+ // Constants
67
+ export const hardline: Hardline = { type: 'hardline' };
68
+ export const softline: Softline = { type: 'softline' };
69
+ export const line: Line = { type: 'line' };
70
+ export const breakParent: BreakParent = { type: 'breakParent' };
71
+ export const empty = '';
72
+
73
+ /**
74
+ * Create a literal text node.
75
+ */
76
+ export function text(value: string): string {
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * Concatenate multiple docs into a single doc.
82
+ */
83
+ export function concat(parts: Doc[]): Doc {
84
+ // Flatten nested concats and filter empty strings
85
+ const flattened: Doc[] = [];
86
+ for (const part of parts) {
87
+ if (part === '') continue;
88
+ if (typeof part === 'object' && part.type === 'concat') {
89
+ flattened.push(...part.parts);
90
+ } else {
91
+ flattened.push(part);
92
+ }
93
+ }
94
+
95
+ if (flattened.length === 0) return '';
96
+ if (flattened.length === 1) return flattened[0];
97
+
98
+ return { type: 'concat', parts: flattened };
99
+ }
100
+
101
+ /**
102
+ * Indent the contents by one level.
103
+ */
104
+ export function indent(contents: Doc): Doc {
105
+ if (contents === '') return '';
106
+ return { type: 'indent', contents };
107
+ }
108
+
109
+ /**
110
+ * Indent the contents by N levels.
111
+ */
112
+ export function indentN(contents: Doc, n: number): Doc {
113
+ if (n <= 0 || contents === '') return contents;
114
+ let result = contents;
115
+ for (let i = 0; i < n; i++) {
116
+ result = indent(result);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Create a group that may be printed flat or broken across lines.
123
+ * When `shouldBreak` is true, the group will always break.
124
+ * When `id` is set, the group's mode can be referenced by `ifBreak` nodes.
125
+ */
126
+ export function group(contents: Doc, options?: { shouldBreak?: boolean; id?: symbol }): Doc {
127
+ if (contents === '') return '';
128
+ const shouldBreak = options?.shouldBreak;
129
+ return { type: 'group', contents, break: shouldBreak || undefined, id: options?.id };
130
+ }
131
+
132
+ /**
133
+ * Create an ifBreak node that prints different content depending on
134
+ * whether the enclosing group breaks or stays flat.
135
+ * When `groupId` is set, checks that specific group's mode instead.
136
+ */
137
+ export function ifBreak(breakContents: Doc, flatContents: Doc, options?: { groupId?: symbol }): Doc {
138
+ return { type: 'ifBreak', breakContents, flatContents, groupId: options?.groupId };
139
+ }
140
+
141
+ /**
142
+ * Create a fill for inline content that wraps when needed.
143
+ * Parts alternate between content and separators.
144
+ */
145
+ export function fill(parts: Doc[]): Doc {
146
+ const filtered = parts.filter((p) => p !== '');
147
+ if (filtered.length === 0) return '';
148
+ if (filtered.length === 1) return filtered[0];
149
+ return { type: 'fill', parts: filtered };
150
+ }
151
+
152
+ /**
153
+ * Join docs with a separator.
154
+ */
155
+ export function join(separator: Doc, docs: Doc[]): Doc {
156
+ const parts: Doc[] = [];
157
+ for (let i = 0; i < docs.length; i++) {
158
+ if (docs[i] === '') continue;
159
+ if (parts.length > 0) {
160
+ parts.push(separator);
161
+ }
162
+ parts.push(docs[i]);
163
+ }
164
+ return concat(parts);
165
+ }
166
+
167
+ // Type guards for working with Doc types
168
+ export function isConcat(doc: Doc): doc is Concat {
169
+ return typeof doc === 'object' && doc.type === 'concat';
170
+ }
171
+
172
+ export function isIndent(doc: Doc): doc is Indent {
173
+ return typeof doc === 'object' && doc.type === 'indent';
174
+ }
175
+
176
+ export function isHardline(doc: Doc): doc is Hardline {
177
+ return typeof doc === 'object' && doc.type === 'hardline';
178
+ }
179
+
180
+ export function isSoftline(doc: Doc): doc is Softline {
181
+ return typeof doc === 'object' && doc.type === 'softline';
182
+ }
183
+
184
+ export function isLine(doc: Doc): doc is Line {
185
+ return typeof doc === 'object' && doc.type === 'line';
186
+ }
187
+
188
+ export function isGroup(doc: Doc): doc is Group {
189
+ return typeof doc === 'object' && doc.type === 'group';
190
+ }
191
+
192
+ export function isFill(doc: Doc): doc is Fill {
193
+ return typeof doc === 'object' && doc.type === 'fill';
194
+ }
195
+
196
+ export function isBreakParent(doc: Doc): doc is BreakParent {
197
+ return typeof doc === 'object' && doc.type === 'breakParent';
198
+ }
199
+
200
+ export function isIfBreak(doc: Doc): doc is IfBreak {
201
+ return typeof doc === 'object' && doc.type === 'ifBreak';
202
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pure option merging + indent-unit construction.
3
+ *
4
+ * EditorConfig-aware merging lives in `lsp/server/src/formatting/editorconfig.ts`
5
+ * and layers on top of these results by passing its result as `overrides`.
6
+ */
7
+
8
+ import type { FormattingOptions } from './index.js';
9
+ import type { HtmlMustacheConfig } from '../configSchema.js';
10
+
11
+ /**
12
+ * Merge base options with `configFile` (indentSize only) and optional
13
+ * `overrides` (tabSize / insertSpaces). Pure; no fs.
14
+ *
15
+ * Priority (low → high): lspOptions < configFile.indentSize < overrides.
16
+ * `insertSpaces` never comes from `configFile` — only `lspOptions` or `overrides`.
17
+ */
18
+ export function mergeOptions(
19
+ lspOptions: FormattingOptions,
20
+ configFile?: HtmlMustacheConfig | null,
21
+ overrides?: Partial<FormattingOptions>,
22
+ ): FormattingOptions {
23
+ let tabSize = lspOptions.tabSize;
24
+ if (configFile?.indentSize !== undefined) tabSize = configFile.indentSize;
25
+ if (overrides?.tabSize !== undefined) tabSize = overrides.tabSize;
26
+
27
+ const insertSpaces = overrides?.insertSpaces ?? lspOptions.insertSpaces;
28
+
29
+ return { tabSize, insertSpaces };
30
+ }
31
+
32
+ export function createIndentUnit(options: FormattingOptions): string {
33
+ return options.insertSpaces ? ' '.repeat(options.tabSize) : '\t';
34
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Printer module - converts Doc IR to formatted string.
3
+ *
4
+ * The printer traverses the Doc tree and produces a string with proper
5
+ * indentation and line breaks.
6
+ */
7
+
8
+ import type { Doc } from './ir.js';
9
+
10
+ export interface PrinterOptions {
11
+ /** The indentation string (e.g., ' ' for 2 spaces or '\t' for tab) */
12
+ indentUnit: string;
13
+ /** Maximum line width before breaking (used for group fitting) */
14
+ printWidth?: number;
15
+ }
16
+
17
+ interface PrintState {
18
+ indentLevel: number;
19
+ mode: 'flat' | 'break';
20
+ groupModes: Map<symbol, 'flat' | 'break'>;
21
+ }
22
+
23
+ /**
24
+ * Print a Doc to a string with the given options.
25
+ */
26
+ export function print(doc: Doc, options: PrinterOptions): string {
27
+ const output: string[] = [];
28
+ const state: PrintState = { indentLevel: 0, mode: 'break', groupModes: new Map() };
29
+
30
+ printDoc(doc, state, output, options);
31
+
32
+ return output.join('');
33
+ }
34
+
35
+ /**
36
+ * Walk the output buffer backward to find the current column position
37
+ * (characters since the last newline).
38
+ */
39
+ function currentColumn(output: string[]): number {
40
+ let col = 0;
41
+ for (let i = output.length - 1; i >= 0; i--) {
42
+ const chunk = output[i];
43
+ const nlIndex = chunk.lastIndexOf('\n');
44
+ if (nlIndex !== -1) {
45
+ col += chunk.length - nlIndex - 1;
46
+ return col;
47
+ }
48
+ col += chunk.length;
49
+ }
50
+ return col;
51
+ }
52
+
53
+ /**
54
+ * Check if a Doc tree contains a breakParent anywhere.
55
+ */
56
+ function containsBreakParent(doc: Doc): boolean {
57
+ if (typeof doc === 'string') return false;
58
+ switch (doc.type) {
59
+ case 'breakParent':
60
+ return true;
61
+ case 'concat':
62
+ return doc.parts.some(containsBreakParent);
63
+ case 'indent':
64
+ return containsBreakParent(doc.contents);
65
+ case 'group':
66
+ return containsBreakParent(doc.contents);
67
+ case 'fill':
68
+ return doc.parts.some(containsBreakParent);
69
+ case 'ifBreak':
70
+ return (
71
+ containsBreakParent(doc.breakContents) ||
72
+ containsBreakParent(doc.flatContents)
73
+ );
74
+ default:
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function printDoc(
80
+ doc: Doc,
81
+ state: PrintState,
82
+ output: string[],
83
+ options: PrinterOptions
84
+ ): void {
85
+ if (typeof doc === 'string') {
86
+ output.push(doc);
87
+ return;
88
+ }
89
+
90
+ switch (doc.type) {
91
+ case 'concat':
92
+ for (const part of doc.parts) {
93
+ printDoc(part, state, output, options);
94
+ }
95
+ break;
96
+
97
+ case 'indent':
98
+ state.indentLevel++;
99
+ printDoc(doc.contents, state, output, options);
100
+ state.indentLevel--;
101
+ break;
102
+
103
+ case 'hardline':
104
+ output.push('\n');
105
+ output.push(makeIndent(state.indentLevel, options));
106
+ break;
107
+
108
+ case 'softline':
109
+ if (state.mode === 'break') {
110
+ output.push('\n');
111
+ output.push(makeIndent(state.indentLevel, options));
112
+ }
113
+ // In flat mode, softline produces nothing
114
+ break;
115
+
116
+ case 'line':
117
+ if (state.mode === 'break') {
118
+ output.push('\n');
119
+ output.push(makeIndent(state.indentLevel, options));
120
+ } else {
121
+ // In flat mode, line produces a space
122
+ output.push(' ');
123
+ }
124
+ break;
125
+
126
+ case 'group': {
127
+ if (doc.break || containsBreakParent(doc.contents)) {
128
+ // Forced break
129
+ const prevMode = state.mode;
130
+ state.mode = 'break';
131
+ if (doc.id) state.groupModes.set(doc.id, 'break');
132
+ printDoc(doc.contents, state, output, options);
133
+ state.mode = prevMode;
134
+ } else {
135
+ // Try to fit on one line
136
+ const flatOutput: string[] = [];
137
+ const flatState: PrintState = { ...state, mode: 'flat', groupModes: new Map(state.groupModes) };
138
+ printDoc(doc.contents, flatState, flatOutput, options);
139
+
140
+ const flatContent = flatOutput.join('');
141
+ const printWidth = options.printWidth ?? 80;
142
+ const col = currentColumn(output);
143
+
144
+ // Check if it fits (no newlines and within width from current column)
145
+ if (
146
+ !flatContent.includes('\n') &&
147
+ col + flatContent.length <= printWidth
148
+ ) {
149
+ if (doc.id) state.groupModes.set(doc.id, 'flat');
150
+ output.push(flatContent);
151
+ } else {
152
+ // Break mode
153
+ const prevMode = state.mode;
154
+ state.mode = 'break';
155
+ if (doc.id) state.groupModes.set(doc.id, 'break');
156
+ printDoc(doc.contents, state, output, options);
157
+ state.mode = prevMode;
158
+ }
159
+ }
160
+ break;
161
+ }
162
+
163
+ case 'fill':
164
+ printFill(doc.parts, state, output, options);
165
+ break;
166
+
167
+ case 'ifBreak': {
168
+ const effectiveMode = doc.groupId
169
+ ? (state.groupModes.get(doc.groupId) ?? state.mode)
170
+ : state.mode;
171
+ if (effectiveMode === 'break') {
172
+ printDoc(doc.breakContents, state, output, options);
173
+ } else {
174
+ printDoc(doc.flatContents, state, output, options);
175
+ }
176
+ break;
177
+ }
178
+
179
+ case 'breakParent':
180
+ // breakParent is handled by containsBreakParent() in group evaluation.
181
+ // If we reach here outside a group, force break mode.
182
+ state.mode = 'break';
183
+ break;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Print fill: content and separator pairs, keeping items on the same line
189
+ * when they fit, breaking when they don't.
190
+ * Parts alternate: [content, separator, content, separator, ..., content]
191
+ */
192
+ function printFill(
193
+ parts: Doc[],
194
+ state: PrintState,
195
+ output: string[],
196
+ options: PrinterOptions
197
+ ): void {
198
+ if (parts.length === 0) return;
199
+
200
+ const printWidth = options.printWidth ?? 80;
201
+
202
+ for (let i = 0; i < parts.length; i++) {
203
+ const content = parts[i];
204
+ const separator = i + 1 < parts.length ? parts[i + 1] : null;
205
+
206
+ // Print the content
207
+ printDoc(content, state, output, options);
208
+
209
+ if (separator === null) break;
210
+
211
+ // Try printing separator + next content flat
212
+ const nextContent = i + 2 < parts.length ? parts[i + 2] : null;
213
+ if (nextContent !== null) {
214
+ const testOutput: string[] = [];
215
+ const flatState: PrintState = { ...state, mode: 'flat' };
216
+ printDoc(separator, flatState, testOutput, options);
217
+ printDoc(nextContent, flatState, testOutput, options);
218
+ const testStr = testOutput.join('');
219
+ const col = currentColumn(output);
220
+
221
+ if (!testStr.includes('\n') && col + testStr.length <= printWidth) {
222
+ // Fits: print separator flat
223
+ const sepOutput: string[] = [];
224
+ printDoc(separator, flatState, sepOutput, options);
225
+ output.push(sepOutput.join(''));
226
+ } else {
227
+ // Doesn't fit: print separator in break mode
228
+ printDoc(separator, { ...state, mode: 'break' }, output, options);
229
+ }
230
+ } else {
231
+ // Last separator with no following content, print in current mode
232
+ printDoc(separator, state, output, options);
233
+ }
234
+
235
+ // Skip the separator in the loop
236
+ i++;
237
+ }
238
+ }
239
+
240
+ function makeIndent(level: number, options: PrinterOptions): string {
241
+ return options.indentUnit.repeat(level);
242
+ }