@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,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
|
+
}
|