Skip to content

Commit cf2243d

Browse files
committed
Refactor
1 parent 111564b commit cf2243d

File tree

7 files changed

+1598
-393
lines changed

7 files changed

+1598
-393
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import {
2+
isERBCommentNode,
3+
isLiteralNode,
4+
createLiteral,
5+
HTMLCommentNode,
6+
Token,
7+
Location as HerbLocation,
8+
Range as HerbRange,
9+
} from "@herb-tools/core"
10+
11+
import { IdentityPrinter } from "@herb-tools/printer"
12+
import { asMutable } from "@herb-tools/rewriter"
13+
14+
import { ParserService } from "./parser_service"
15+
import { LineContextCollector } from "./line_context_collector"
16+
17+
import type {
18+
Node,
19+
ERBContentNode,
20+
} from "@herb-tools/core"
21+
22+
/**
23+
* Commenting strategy for a line:
24+
* - "single-erb": sole ERB tag on the line → insert # at column offset
25+
* - "all-erb": multiple ERB tags with no significant HTML → # into each
26+
* - "per-segment": control-flow ERB wrapping HTML → # each ERB, <!-- --> each HTML segment
27+
* - "whole-line": output ERB mixed with HTML → wrap entire line in <!-- --> with ERB #
28+
* - "html-only": pure HTML content → wrap in <!-- -->
29+
*/
30+
export type CommentStrategy = "single-erb" | "all-erb" | "per-segment" | "whole-line" | "html-only"
31+
32+
interface LineSegment {
33+
text: string
34+
isERB: boolean
35+
node?: ERBContentNode
36+
}
37+
38+
export function createSyntheticToken(type: string, value: string): Token {
39+
return new Token(value, HerbRange.zero, HerbLocation.zero, type)
40+
}
41+
42+
export function commentERBNode(node: ERBContentNode): void {
43+
const mutable = asMutable(node)
44+
45+
if (mutable.tag_opening) {
46+
const currentValue = mutable.tag_opening.value
47+
mutable.tag_opening = createSyntheticToken(
48+
mutable.tag_opening.type,
49+
currentValue.substring(0, 2) + "#" + currentValue.substring(2)
50+
)
51+
}
52+
}
53+
54+
export function uncommentERBNode(node: ERBContentNode): void {
55+
const mutable = asMutable(node)
56+
57+
if (mutable.tag_opening && mutable.tag_opening.value === "<%#") {
58+
const contentValue = mutable.content?.value || ""
59+
60+
if (
61+
contentValue.startsWith(" graphql ") ||
62+
contentValue.startsWith(" %= ") ||
63+
contentValue.startsWith(" == ") ||
64+
contentValue.startsWith(" % ") ||
65+
contentValue.startsWith(" = ") ||
66+
contentValue.startsWith(" - ")
67+
) {
68+
mutable.tag_opening = createSyntheticToken(mutable.tag_opening.type, "<%")
69+
mutable.content = createSyntheticToken(mutable.content!.type, contentValue.substring(1))
70+
} else {
71+
mutable.tag_opening = createSyntheticToken(mutable.tag_opening.type, "<%")
72+
}
73+
}
74+
}
75+
76+
export function determineStrategy(erbNodes: ERBContentNode[], lineText: string): CommentStrategy {
77+
if (erbNodes.length === 0) {
78+
return "html-only"
79+
}
80+
81+
if (erbNodes.length === 1) {
82+
const node = erbNodes[0]
83+
if (!node.tag_opening || !node.tag_closing) return "html-only"
84+
85+
const nodeStart = node.tag_opening.location.start.column
86+
const nodeEnd = node.tag_closing.location.end.column
87+
const isSoleContent = lineText.substring(0, nodeStart).trim() === "" && lineText.substring(nodeEnd).trim() === ""
88+
89+
if (isSoleContent) {
90+
return "single-erb"
91+
}
92+
93+
return "whole-line"
94+
}
95+
96+
const segments = getLineSegments(lineText, erbNodes)
97+
const hasHTML = segments.some(s => !s.isERB && s.text.trim() !== "")
98+
99+
if (!hasHTML) {
100+
return "all-erb"
101+
}
102+
103+
const allControlTags = erbNodes.every(n => n.tag_opening?.value === "<%")
104+
105+
if (allControlTags) {
106+
return "per-segment"
107+
}
108+
109+
return "whole-line"
110+
}
111+
112+
function getLineSegments(lineText: string, erbNodes: ERBContentNode[]): LineSegment[] {
113+
const segments: LineSegment[] = []
114+
let pos = 0
115+
116+
const sorted = [...erbNodes].sort(
117+
(a, b) => a.tag_opening!.location.start.column - b.tag_opening!.location.start.column
118+
)
119+
120+
for (const node of sorted) {
121+
const nodeStart = node.tag_opening!.location.start.column
122+
const nodeEnd = node.tag_closing!.location.end.column
123+
124+
if (nodeStart > pos) {
125+
segments.push({ text: lineText.substring(pos, nodeStart), isERB: false })
126+
}
127+
128+
segments.push({ text: lineText.substring(nodeStart, nodeEnd), isERB: true, node })
129+
pos = nodeEnd
130+
}
131+
132+
if (pos < lineText.length) {
133+
segments.push({ text: lineText.substring(pos), isERB: false })
134+
}
135+
136+
return segments
137+
}
138+
139+
/**
140+
* Comment a line using AST mutation for strategies where the parser produces flat children,
141+
* and text-segment manipulation for per-segment (where the parser nests nodes).
142+
*/
143+
export function commentLineContent(
144+
content: string,
145+
erbNodes: ERBContentNode[],
146+
strategy: CommentStrategy,
147+
parserService: ParserService
148+
): string {
149+
if (strategy === "per-segment") {
150+
return commentPerSegment(content, erbNodes)
151+
}
152+
153+
const parseResult = parserService.parseContent(content, { track_whitespace: true })
154+
const lineCollector = new LineContextCollector()
155+
parseResult.visit(lineCollector)
156+
const lineERBNodes = lineCollector.erbNodesPerLine.get(0) || []
157+
const doc = parseResult.value
158+
const children = asMutable(doc).children
159+
160+
switch (strategy) {
161+
case "all-erb":
162+
for (const node of lineERBNodes) {
163+
commentERBNode(node)
164+
}
165+
break
166+
167+
case "whole-line": {
168+
for (const node of lineERBNodes) {
169+
commentERBNode(node)
170+
}
171+
172+
const commentNode = new HTMLCommentNode({
173+
type: "AST_HTML_COMMENT_NODE",
174+
location: HerbLocation.zero,
175+
errors: [],
176+
comment_start: createSyntheticToken("TOKEN_HTML_COMMENT_START", "<!--"),
177+
children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")],
178+
comment_end: createSyntheticToken("TOKEN_HTML_COMMENT_END", "-->"),
179+
})
180+
181+
children.length = 0
182+
children.push(commentNode)
183+
break
184+
}
185+
186+
case "html-only": {
187+
const commentNode = new HTMLCommentNode({
188+
type: "AST_HTML_COMMENT_NODE",
189+
location: HerbLocation.zero,
190+
errors: [],
191+
comment_start: createSyntheticToken("TOKEN_HTML_COMMENT_START", "<!--"),
192+
children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")],
193+
comment_end: createSyntheticToken("TOKEN_HTML_COMMENT_END", "-->"),
194+
})
195+
196+
children.length = 0
197+
children.push(commentNode)
198+
break
199+
}
200+
}
201+
202+
return IdentityPrinter.print(doc, { ignoreErrors: true })
203+
}
204+
205+
/**
206+
* Per-segment commenting uses text segments because the parser creates nested
207+
* structures (e.g., ERBIfNode) that don't allow flat child iteration.
208+
*/
209+
function commentPerSegment(content: string, erbNodes: ERBContentNode[]): string {
210+
const segments = getLineSegments(content, erbNodes)
211+
212+
return segments.map(segment => {
213+
if (segment.isERB) {
214+
return segment.text.substring(0, 2) + "#" + segment.text.substring(2)
215+
} else if (segment.text.trim() !== "") {
216+
return `<!-- ${segment.text} -->`
217+
}
218+
219+
return segment.text
220+
}).join("")
221+
}
222+
223+
export function uncommentLineContent(content: string, parserService: ParserService): string {
224+
const parseResult = parserService.parseContent(content, { track_whitespace: true })
225+
const lineCollector = new LineContextCollector()
226+
227+
parseResult.visit(lineCollector)
228+
229+
const lineERBNodes = lineCollector.erbNodesPerLine.get(0) || []
230+
const document = parseResult.value
231+
const children = asMutable(document).children
232+
233+
for (const node of lineERBNodes) {
234+
if (isERBCommentNode(node)) {
235+
uncommentERBNode(node)
236+
}
237+
}
238+
239+
let i = 0
240+
241+
while (i < children.length) {
242+
const child = children[i]
243+
244+
if (child.type === "AST_HTML_COMMENT_NODE") {
245+
const commentNode = child as HTMLCommentNode
246+
const innerChildren = [...commentNode.children]
247+
248+
if (innerChildren.length > 0) {
249+
const first = innerChildren[0]
250+
251+
if (isLiteralNode(first) && first.content.startsWith(" ")) {
252+
const trimmed = first.content.substring(1)
253+
254+
if (trimmed === "") {
255+
innerChildren.shift()
256+
} else {
257+
innerChildren[0] = createLiteral(trimmed)
258+
}
259+
}
260+
}
261+
262+
if (innerChildren.length > 0) {
263+
const last = innerChildren[innerChildren.length - 1]
264+
265+
if (isLiteralNode(last) && last.content.endsWith(" ")) {
266+
const trimmed = last.content.substring(0, last.content.length - 1)
267+
268+
if (trimmed === "") {
269+
innerChildren.pop()
270+
} else {
271+
innerChildren[innerChildren.length - 1] = createLiteral(trimmed)
272+
}
273+
}
274+
}
275+
276+
const innerERBNodes: ERBContentNode[] = []
277+
const innerCollector = new LineContextCollector()
278+
279+
for (const innerChild of innerChildren) {
280+
innerCollector.visit(innerChild)
281+
}
282+
283+
innerERBNodes.push(...(innerCollector.erbNodesPerLine.get(0) || []))
284+
285+
for (const erbNode of innerERBNodes) {
286+
if (isERBCommentNode(erbNode)) {
287+
uncommentERBNode(erbNode)
288+
}
289+
}
290+
291+
children.splice(i, 1, ...innerChildren)
292+
i += innerChildren.length
293+
continue
294+
}
295+
296+
i++
297+
}
298+
299+
return IdentityPrinter.print(document, { ignoreErrors: true })
300+
}

0 commit comments

Comments
 (0)