Skip to content

Commit 6f0cb93

Browse files
authored
Language Server: Implement Toggle Comment Service (#1308)
https://github.com/user-attachments/assets/13cc88a8-bba9-420f-aac3-9e1622829ec5 Resolves #136
1 parent 76053a7 commit 6f0cb93

File tree

68 files changed

+1560
-55
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+1560
-55
lines changed
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { TextEdit, Range, Position } from "vscode-languageserver/node"
2+
import { TextDocument } from "vscode-languageserver-textdocument"
3+
4+
import { Visitor } from "@herb-tools/core"
5+
import { ParserService } from "./parser_service"
6+
7+
import { lspLine } from "./range_utils"
8+
import { isERBCommentNode } from "@herb-tools/core"
9+
10+
import type {
11+
Node,
12+
ERBNode,
13+
ERBContentNode,
14+
HTMLCommentNode,
15+
HTMLTextNode,
16+
HTMLElementNode,
17+
} from "@herb-tools/core"
18+
19+
type LineContext = "erb-comment" | "html-comment" | "erb-tag" | "html-content" | "empty"
20+
21+
interface LineInfo {
22+
line: number
23+
context: LineContext
24+
node: Node | null
25+
}
26+
27+
class LineContextCollector extends Visitor {
28+
public lineMap: Map<number, LineInfo> = new Map()
29+
30+
visitERBNode(node: ERBNode): void {
31+
if (!node.tag_opening || !node.tag_closing) return
32+
33+
const startLine = lspLine(node.tag_opening.location.start)
34+
35+
if (isERBCommentNode(node)) {
36+
this.setLine(startLine, "erb-comment", node)
37+
} else {
38+
this.setLine(startLine, "erb-tag", node)
39+
}
40+
}
41+
42+
visitERBContentNode(node: ERBContentNode): void {
43+
this.visitERBNode(node)
44+
this.visitChildNodes(node)
45+
}
46+
47+
visitHTMLCommentNode(node: HTMLCommentNode): void {
48+
const startLine = lspLine(node.location.start)
49+
const endLine = lspLine(node.location.end)
50+
51+
for (let line = startLine; line <= endLine; line++) {
52+
this.setLine(line, "html-comment", node)
53+
}
54+
55+
this.visitChildNodes(node)
56+
}
57+
58+
visitHTMLElementNode(node: HTMLElementNode): void {
59+
const startLine = lspLine(node.location.start)
60+
const endLine = lspLine(node.location.end)
61+
62+
for (let line = startLine; line <= endLine; line++) {
63+
if (!this.lineMap.has(line)) {
64+
this.setLine(line, "html-content", node)
65+
}
66+
}
67+
68+
this.visitChildNodes(node)
69+
}
70+
71+
visitHTMLTextNode(node: HTMLTextNode): void {
72+
const startLine = lspLine(node.location.start)
73+
const endLine = lspLine(node.location.end)
74+
75+
for (let line = startLine; line <= endLine; line++) {
76+
if (!this.lineMap.has(line)) {
77+
this.setLine(line, "html-content", node)
78+
}
79+
}
80+
81+
this.visitChildNodes(node)
82+
}
83+
84+
private setLine(line: number, context: LineContext, node: Node): void {
85+
const existing = this.lineMap.get(line)
86+
87+
if (existing) {
88+
if (existing.context === "erb-comment" || existing.context === "erb-tag") return
89+
90+
if (context === "erb-comment" || context === "erb-tag") {
91+
this.lineMap.set(line, { line, context, node })
92+
93+
return
94+
}
95+
96+
if (existing.context === "html-comment") return
97+
}
98+
99+
this.lineMap.set(line, { line, context, node })
100+
}
101+
}
102+
103+
export class CommentService {
104+
private parserService: ParserService
105+
106+
constructor(parserService: ParserService) {
107+
this.parserService = parserService
108+
}
109+
110+
toggleLineComment(document: TextDocument, range: Range): TextEdit[] {
111+
const parseResult = this.parserService.parseDocument(document)
112+
const collector = new LineContextCollector()
113+
114+
collector.visit(parseResult.document)
115+
116+
const startLine = range.start.line
117+
const endLine = range.end.line
118+
const lineInfos: LineInfo[] = []
119+
120+
for (let line = startLine; line <= endLine; line++) {
121+
const lineText = document.getText(Range.create(line, 0, line + 1, 0)).replace(/\n$/, "")
122+
123+
if (lineText.trim() === "") {
124+
continue
125+
}
126+
127+
const info = collector.lineMap.get(line)
128+
const trimmed = lineText.trim()
129+
130+
if (trimmed.startsWith("<!--") && trimmed.endsWith("-->")) {
131+
lineInfos.push({ line, context: "html-comment", node: null })
132+
} else if (info) {
133+
lineInfos.push(info)
134+
} else {
135+
lineInfos.push({ line, context: "html-content", node: null })
136+
}
137+
}
138+
139+
if (lineInfos.length === 0) return []
140+
141+
const allCommented = lineInfos.every(
142+
info => info.context === "erb-comment" || info.context === "html-comment"
143+
)
144+
145+
const edits: TextEdit[] = []
146+
147+
if (allCommented) {
148+
for (const info of lineInfos) {
149+
const lineText = document.getText(Range.create(info.line, 0, info.line + 1, 0)).replace(/\n$/, "")
150+
const edit = this.uncommentLine(info, lineText)
151+
152+
if (edit) edits.push(edit)
153+
}
154+
} else {
155+
for (const info of lineInfos) {
156+
if (info.context === "erb-comment" || info.context === "html-comment") continue
157+
158+
const lineText = document.getText(Range.create(info.line, 0, info.line + 1, 0)).replace(/\n$/, "")
159+
const edit = this.commentLine(info, lineText)
160+
161+
if (edit) edits.push(edit)
162+
}
163+
}
164+
165+
return edits
166+
}
167+
168+
toggleBlockComment(document: TextDocument, range: Range): TextEdit[] {
169+
const startLine = range.start.line
170+
const endLine = range.end.line
171+
172+
const firstLineText = document.getText(Range.create(startLine, 0, startLine + 1, 0)).replace(/\n$/, "")
173+
const lastLineText = document.getText(Range.create(endLine, 0, endLine + 1, 0)).replace(/\n$/, "")
174+
const isWrapped = firstLineText.trim() === "<% if false %>" && lastLineText.trim() === "<% end %>"
175+
176+
if (isWrapped) {
177+
return [
178+
TextEdit.del(Range.create(endLine, 0, endLine + 1, 0)),
179+
TextEdit.del(Range.create(startLine, 0, startLine + 1, 0)),
180+
]
181+
} else {
182+
const firstLineIndent = this.getIndentation(firstLineText)
183+
184+
return [
185+
TextEdit.insert(Position.create(endLine + 1, 0), `${firstLineIndent}<% end %>\n`),
186+
TextEdit.insert(Position.create(startLine, 0), `${firstLineIndent}<% if false %>\n`),
187+
]
188+
}
189+
}
190+
191+
private commentLine(info: LineInfo, lineText: string): TextEdit | null {
192+
const lineRange = Range.create(info.line, 0, info.line, lineText.length)
193+
194+
if (info.context === "erb-tag") {
195+
const isSingleERBTag = /^\s*<%(?:(?!%>).)*%>\s*$/.test(lineText)
196+
197+
if (!isSingleERBTag) {
198+
const indent = this.getIndentation(lineText)
199+
const content = lineText.trimStart()
200+
201+
return TextEdit.replace(lineRange, `${indent}<!-- ${content} -->`)
202+
}
203+
204+
const node = info.node as ERBContentNode
205+
if (!node?.tag_opening) return null
206+
207+
if (lspLine(node.tag_opening.location.start) !== info.line) {
208+
return null
209+
}
210+
211+
const insertColumn = node.tag_opening.location.start.column + 2
212+
213+
return TextEdit.insert(Position.create(info.line, insertColumn), "#")
214+
}
215+
216+
if (info.context === "html-content") {
217+
const indent = this.getIndentation(lineText)
218+
const content = lineText.trimStart()
219+
220+
return TextEdit.replace(lineRange, `${indent}<!-- ${content} -->`)
221+
}
222+
223+
return null
224+
}
225+
226+
private uncommentLine(info: LineInfo, lineText: string): TextEdit | null {
227+
const lineRange = Range.create(info.line, 0, info.line, lineText.length)
228+
229+
if (info.context === "erb-comment") {
230+
const node = info.node as ERBContentNode
231+
if (!node?.tag_opening || !node?.tag_closing) return null
232+
233+
const contentValue = (node as any).content?.value as string | null
234+
const trimmedContent = contentValue?.trim() || ""
235+
236+
if (trimmedContent.startsWith("<") && !trimmedContent.startsWith("<%")) {
237+
const indent = this.getIndentation(lineText)
238+
239+
return TextEdit.replace(lineRange, `${indent}${trimmedContent}`)
240+
}
241+
242+
if (lspLine(node.tag_opening.location.start) !== info.line) return null
243+
244+
const hashColumn = node.tag_opening.location.start.column + 2
245+
246+
if (
247+
contentValue?.startsWith(" graphql ") ||
248+
contentValue?.startsWith(" %= ") ||
249+
contentValue?.startsWith(" == ") ||
250+
contentValue?.startsWith(" % ") ||
251+
contentValue?.startsWith(" = ") ||
252+
contentValue?.startsWith(" - ")
253+
) {
254+
return TextEdit.del(Range.create(info.line, hashColumn, info.line, hashColumn + 2))
255+
}
256+
257+
return TextEdit.del(Range.create(info.line, hashColumn, info.line, hashColumn + 1))
258+
}
259+
260+
if (info.context === "html-comment") {
261+
const indent = this.getIndentation(lineText)
262+
const match = lineText.match(/<!--\s*(.*?)\s*-->/)
263+
264+
if (match) {
265+
return TextEdit.replace(lineRange, `${indent}${match[1]}`)
266+
}
267+
}
268+
269+
return null
270+
}
271+
272+
private getIndentation(lineText: string): string {
273+
const match = lineText.match(/^(\s*)/)
274+
275+
return match ? match[1] : ""
276+
}
277+
}

javascript/packages/language-server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from "./utils"
1010
export * from "./range_utils"
1111
export * from "./cli"
1212
export * from "./document_highlight_service"
13+
export * from "./comment_service"

javascript/packages/language-server/src/server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
CodeActionKind,
1515
FoldingRangeParams,
1616
DocumentHighlightParams,
17+
TextDocumentIdentifier,
18+
Range,
1719
} from "vscode-languageserver/node"
1820

1921
import { Service } from "./service"
@@ -195,6 +197,22 @@ export class Server {
195197

196198
return this.service.foldingRangeService.getFoldingRanges(document)
197199
})
200+
201+
this.connection.onRequest('herb/toggleLineComment', (params: { textDocument: TextDocumentIdentifier, range: Range }) => {
202+
const document = this.service.documentService.get(params.textDocument.uri)
203+
204+
if (!document) return []
205+
206+
return this.service.commentService.toggleLineComment(document, params.range)
207+
})
208+
209+
this.connection.onRequest('herb/toggleBlockComment', (params: { textDocument: TextDocumentIdentifier, range: Range }) => {
210+
const document = this.service.documentService.get(params.textDocument.uri)
211+
212+
if (!document) return []
213+
214+
return this.service.commentService.toggleBlockComment(document, params.range)
215+
})
198216
}
199217

200218
listen() {

javascript/packages/language-server/src/service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { CodeActionService } from "./code_action_service"
1414
import { DocumentSaveService } from "./document_save_service"
1515
import { FoldingRangeService } from "./folding_range_service"
1616
import { DocumentHighlightService } from "./document_highlight_service"
17+
import { CommentService } from "./comment_service"
1718

1819
import { version } from "../package.json"
1920

@@ -34,6 +35,7 @@ export class Service {
3435
documentSaveService: DocumentSaveService
3536
foldingRangeService: FoldingRangeService
3637
documentHighlightService: DocumentHighlightService
38+
commentService: CommentService
3739

3840
constructor(connection: Connection, params: InitializeParams) {
3941
this.connection = connection
@@ -50,6 +52,7 @@ export class Service {
5052
this.documentSaveService = new DocumentSaveService(this.connection, this.settings, this.autofixService, this.formattingService)
5153
this.foldingRangeService = new FoldingRangeService(this.parserService)
5254
this.documentHighlightService = new DocumentHighlightService(this.parserService)
55+
this.commentService = new CommentService(this.parserService)
5356

5457
if (params.initializationOptions) {
5558
this.settings.globalSettings = params.initializationOptions as PersonalHerbSettings

0 commit comments

Comments
 (0)