Skip to content

Commit 301f934

Browse files
authored
Language Server: Improve edge cases in Comment Service (#1329)
This pull request refactors the `CommentService` in the Language Server to use an AST-based approach for toggling comments for each individual node, instead of the previous regex/string-matching based approach we used before. This also solves a few edge cases with how the service toggled comments. The service now picks one of five strategies based on what's on the line: | Strategy | Input | Output | |-------------|----------------------------------------|---------------------------------------------------| | `single-erb` | `<%= render "thing" %>` | `<%#= render "thing" %>` | | `all-erb` | `<% if true %><% end %>` | `<%# if true %><%# end %>` | | `per-segment` | `<% if true %>hello<% end %>` | `<%# if true %><!-- hello --><%# end %>` | | `whole-line` | `<h1><%= @title %></h1>` | `<!-- <h1><%#= @title %></h1> -->` | | `html-only` | `<div>hello</div>` | `<!-- <div>hello</div> -->` |
1 parent 95a11e3 commit 301f934

File tree

9 files changed

+1646
-233
lines changed

9 files changed

+1646
-233
lines changed

javascript/packages/language-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"@herb-tools/formatter": "0.8.10",
5050
"@herb-tools/linter": "0.8.10",
5151
"@herb-tools/node-wasm": "0.8.10",
52+
"@herb-tools/printer": "0.8.10",
53+
"@herb-tools/rewriter": "0.8.10",
5254
"dedent": "^1.7.0",
5355
"vscode-languageserver": "^9.0.1",
5456
"vscode-languageserver-textdocument": "^1.0.12"

javascript/packages/language-server/project.json

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

0 commit comments

Comments
 (0)