diff --git a/javascript/packages/language-server/src/comment_ast_utils.ts b/javascript/packages/language-server/src/comment_ast_utils.ts index 665ae5f42..c7a22cc6f 100644 --- a/javascript/packages/language-server/src/comment_ast_utils.ts +++ b/javascript/packages/language-server/src/comment_ast_utils.ts @@ -70,6 +70,12 @@ export function determineStrategy(erbNodes: ERBContentNode[], lineText: string): if (!node.tag_opening || !node.tag_closing) return "html-only" const nodeStart = node.tag_opening.location.start.column + const spansMultipleLines = node.tag_closing.location.end.line > node.tag_opening.location.start.line + + if (spansMultipleLines) { + return lineText.substring(0, nodeStart).trim() === "" ? "single-erb" : "whole-line" + } + const nodeEnd = node.tag_closing.location.end.column const isSoleContent = lineText.substring(0, nodeStart).trim() === "" && lineText.substring(nodeEnd).trim() === "" diff --git a/javascript/packages/language-server/src/comment_service.ts b/javascript/packages/language-server/src/comment_service.ts index cab2a7b1b..cd15b3d49 100644 --- a/javascript/packages/language-server/src/comment_service.ts +++ b/javascript/packages/language-server/src/comment_service.ts @@ -7,6 +7,8 @@ import { LineContextCollector } from "./line_context_collector" import { lspLine } from "./range_utils" import { determineStrategy, commentLineContent, uncommentLineContent } from "./comment_ast_utils" +import { isERBCommentNode } from "@herb-tools/core" + import type { LineInfo } from "./line_context_collector" import type { ERBContentNode, HTMLCommentNode } from "@herb-tools/core" @@ -28,7 +30,7 @@ export class CommentService { const lineInfos: LineInfo[] = [] for (let line = startLine; line <= endLine; line++) { - const lineText = document.getText(Range.create(line, 0, line + 1, 0)).replace(/\n$/, "") + const lineText = this.getLineText(document, line) if (lineText.trim() === "") { continue @@ -45,7 +47,11 @@ export class CommentService { if (htmlCommentNode && this.htmlCommentSpansLine(htmlCommentNode, lineText)) { lineInfos.push({ line, context: "html-comment", node: htmlCommentNode }) } else if (info) { - if (info.context === "html-comment") { + if (info.context === "ruby") { + const context = lineText.trimStart().startsWith("#") ? "erb-comment" : "ruby" + + lineInfos.push({ line, context, node: info.node }) + } else if (info.context === "html-comment") { lineInfos.push({ line, context: "html-content", node: null }) } else { lineInfos.push(info) @@ -65,7 +71,7 @@ export class CommentService { if (allCommented) { for (const info of lineInfos) { - const lineText = document.getText(Range.create(info.line, 0, info.line + 1, 0)).replace(/\n$/, "") + const lineText = this.getLineText(document, info.line) const edit = this.uncommentLine(info, lineText, collector) if (edit) edits.push(edit) @@ -74,7 +80,7 @@ export class CommentService { for (const info of lineInfos) { if (info.context === "erb-comment" || info.context === "html-comment") continue - const lineText = document.getText(Range.create(info.line, 0, info.line + 1, 0)).replace(/\n$/, "") + const lineText = this.getLineText(document, info.line) const erbNodes = collector.erbNodesPerLine.get(info.line) || [] const edit = this.commentLine(info, lineText, erbNodes, collector) @@ -86,11 +92,19 @@ export class CommentService { } toggleBlockComment(document: TextDocument, range: Range): TextEdit[] { - const startLine = range.start.line - const endLine = range.end.line + const parseResult = this.parserService.parseDocument(document) + const collector = new LineContextCollector() + + collector.visit(parseResult.document) + + if (this.selectionIsRubyLines(document, collector, range.start.line, range.end.line)) { + return this.toggleRubyLineComments(document, range.start.line, range.end.line) + } + + const { startLine, endLine } = this.expandAcrossMultilineTags(collector, range.start.line, range.end.line) - const firstLineText = document.getText(Range.create(startLine, 0, startLine + 1, 0)).replace(/\n$/, "") - const lastLineText = document.getText(Range.create(endLine, 0, endLine + 1, 0)).replace(/\n$/, "") + const firstLineText = this.getLineText(document, startLine) + const lastLineText = this.getLineText(document, endLine) const isWrapped = firstLineText.trim() === "<% if false %>" && lastLineText.trim() === "<% end %>" if (isWrapped) { @@ -114,6 +128,10 @@ export class CommentService { const content = lineText.trimStart() const htmlCommentNode = collector.htmlCommentNodesPerLine.get(info.line) + if (info.context === "ruby") { + return TextEdit.insert(Position.create(info.line, indent.length), "# ") + } + if (htmlCommentNode) { return TextEdit.replace(lineRange, `${indent}<% if false %>${content}<% end %>`) } @@ -145,6 +163,63 @@ export class CommentService { return null } + private expandAcrossMultilineTags(collector: LineContextCollector, startLine: number, endLine: number): { startLine: number, endLine: number } { + const startInfo = collector.lineMap.get(startLine) + const endInfo = collector.lineMap.get(endLine) + const startNode = startInfo?.node as ERBContentNode | null + const endNode = endInfo?.node as ERBContentNode | null + + if (startNode?.tag_opening && (startInfo!.context === "ruby" || startInfo!.context === "erb-comment")) { + startLine = Math.min(startLine, lspLine(startNode.tag_opening.location.start)) + } + + if (endNode?.tag_closing && (endInfo!.context === "ruby" || endInfo!.context === "erb-comment" || endInfo!.context === "erb-tag")) { + endLine = Math.max(endLine, lspLine(endNode.tag_closing.location.end)) + } + + return { startLine, endLine } + } + + private selectionIsRubyLines(document: TextDocument, collector: LineContextCollector, startLine: number, endLine: number): boolean { + let hasRubyLine = false + + for (let line = startLine; line <= endLine; line++) { + const lineText = this.getLineText(document, line) + + if (lineText.trim() === "") continue + if (collector.lineMap.get(line)?.context !== "ruby") return false + + hasRubyLine = true + } + + return hasRubyLine + } + + private toggleRubyLineComments(document: TextDocument, startLine: number, endLine: number): TextEdit[] { + const lineTexts: { line: number, text: string }[] = [] + + for (let line = startLine; line <= endLine; line++) { + const text = this.getLineText(document, line) + + if (text.trim() !== "") { + lineTexts.push({ line, text }) + } + } + + const allCommented = lineTexts.every(({ text }) => text.trimStart().startsWith("#")) + const edits: TextEdit[] = [] + + for (const { line, text } of lineTexts) { + const edit = allCommented + ? this.uncommentRubyLine(text, line) + : TextEdit.insert(Position.create(line, this.getIndentation(text).length), "# ") + + if (edit) edits.push(edit) + } + + return edits + } + private uncommentLine(info: LineInfo, lineText: string, collector: LineContextCollector): TextEdit | null { const lineRange = Range.create(info.line, 0, info.line, lineText.length) const indent = this.getIndentation(lineText) @@ -155,9 +230,13 @@ export class CommentService { } if (info.context === "erb-comment") { - const node = info.node as ERBContentNode + const node = info.node as ERBContentNode | null if (!node?.tag_opening || !node?.tag_closing) return null + if (!isERBCommentNode(node) || lspLine(node.tag_opening.location.start) !== info.line) { + return this.uncommentRubyLine(lineText, info.line) + } + const contentValue = (node as any).content?.value as string | null const trimmedContent = contentValue?.trim() || "" @@ -165,8 +244,6 @@ export class CommentService { return TextEdit.replace(lineRange, `${indent}${trimmedContent}`) } - if (lspLine(node.tag_opening.location.start) !== info.line) return null - const erbNodes = collector.erbNodesPerLine.get(info.line) || [] if (erbNodes.length > 1) { @@ -209,6 +286,17 @@ export class CommentService { return null } + private uncommentRubyLine(lineText: string, line: number): TextEdit | null { + const content = lineText.trimStart() + + if (!content.startsWith("#")) return null + + const hashColumn = lineText.length - content.length + const deleteLength = content.startsWith("# ") ? 2 : 1 + + return TextEdit.del(Range.create(line, hashColumn, line, hashColumn + deleteLength)) + } + private htmlCommentSpansLine(node: HTMLCommentNode, lineText: string): boolean { if (!node.comment_start || !node.comment_end) return false @@ -220,6 +308,10 @@ export class CommentService { return contentBefore === "" && contentAfter === "" } + private getLineText(document: TextDocument, line: number): string { + return document.getText(Range.create(line, 0, line + 1, 0)).replace(/\n$/, "") + } + private getIndentation(lineText: string): string { const match = lineText.match(/^(\s*)/) diff --git a/javascript/packages/language-server/src/line_context_collector.ts b/javascript/packages/language-server/src/line_context_collector.ts index aca122c91..687f187fc 100644 --- a/javascript/packages/language-server/src/line_context_collector.ts +++ b/javascript/packages/language-server/src/line_context_collector.ts @@ -5,7 +5,7 @@ import { isERBCommentNode } from "@herb-tools/core" import type { Node, ERBNode, ERBContentNode, HTMLTextNode, HTMLElementNode } from "@herb-tools/core" -export type LineContext = "erb-comment" | "html-comment" | "erb-tag" | "html-content" | "empty" +export type LineContext = "erb-comment" | "html-comment" | "erb-tag" | "ruby" | "html-content" | "empty" export interface LineInfo { line: number @@ -22,15 +22,18 @@ export class LineContextCollector extends Visitor { if (!node.tag_opening || !node.tag_closing) return const startLine = lspLine(node.tag_opening.location.start) + const endLine = lspLine(node.tag_closing.location.end) const nodes = this.erbNodesPerLine.get(startLine) || [] nodes.push(node as ERBContentNode) this.erbNodesPerLine.set(startLine, nodes) - if (isERBCommentNode(node)) { - this.setLine(startLine, "erb-comment", node) - } else { - this.setLine(startLine, "erb-tag", node) + this.setLine(startLine, isERBCommentNode(node) ? "erb-comment" : "erb-tag", node) + + const interiorContext = node.tag_opening.value === "<%#" ? "erb-comment" : "ruby" + + for (let line = startLine + 1; line <= endLine; line++) { + this.setLine(line, interiorContext, node) } } @@ -81,9 +84,9 @@ export class LineContextCollector extends Visitor { const existing = this.lineMap.get(line) if (existing) { - if (existing.context === "erb-comment" || existing.context === "erb-tag") return + if (existing.context === "erb-comment" || existing.context === "erb-tag" || existing.context === "ruby") return - if (context === "erb-comment" || context === "erb-tag") { + if (context === "erb-comment" || context === "erb-tag" || context === "ruby") { this.lineMap.set(line, { line, context, node }) return diff --git a/javascript/packages/language-server/test/comment_service.test.ts b/javascript/packages/language-server/test/comment_service.test.ts index 4cc841e95..45f76cdcb 100644 --- a/javascript/packages/language-server/test/comment_service.test.ts +++ b/javascript/packages/language-server/test/comment_service.test.ts @@ -218,6 +218,86 @@ describe("CommentService", () => { expect(applyEdits(original, edits)).toBe(`<%# a %><%# b %><%# c %>`) }) + it("comments an internal Ruby line inside a multiline ERB tag", () => { + const original = `<%\n a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`<%\n # a\n%>`) + }) + + it("comments a multiline ERB tag selection including delimiters", () => { + const original = `<%\n a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(0, 2)) + + expect(applyEdits(original, edits)).toBe(`<%#\n # a\n# %>`) + }) + + it("does not treat a literal <%% escape as an open ERB tag", () => { + const original = `Use <%% to escape\n
hi
` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`Use <%% to escape\n`) + }) + + it("comments an internal Ruby line of a multiline ERB output tag", () => { + const original = `<%=\n a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`<%=\n # a\n%>`) + }) + + it("comments an internal Ruby line of a multiline trim tag", () => { + const original = `<%-\n a\n-%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`<%-\n # a\n-%>`) + }) + + it("comments the opening line of a multiline ERB tag with trailing code as an ERB comment", () => { + const original = `<% x = [1,\n 2,\n] %>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(0)) + + expect(applyEdits(original, edits)).toBe(`<%# x = [1,\n 2,\n] %>`) + }) + + it("comments internal Ruby lines around a blank line", () => { + const original = `<%\n a\n\n b\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1, 3)) + + expect(applyEdits(original, edits)).toBe(`<%\n # a\n\n # b\n%>`) + }) + + it("preserves indentation when commenting inside an indented multiline ERB tag", () => { + const original = `
\n <%\n a\n %>\n
` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(2)) + + expect(applyEdits(original, edits)).toBe(`
\n <%\n # a\n %>\n
`) + }) + + it("comments a mixed selection of HTML and a multiline ERB tag", () => { + const original = `
hi
\n<%\n a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(0, 2)) + + expect(applyEdits(original, edits)).toBe(`\n<%#\n # a\n%>`) + }) + + it("comments the remaining uncommented Ruby lines in a partially commented selection", () => { + const original = `<%\n # a\n b\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1, 2)) + + expect(applyEdits(original, edits)).toBe(`<%\n # a\n # b\n%>`) + }) + it("comments multiple adjacent ERB output tags as all-erb", () => { const original = `<%= a %><%= b %><%= c %>` const document = createDocument(original) @@ -371,6 +451,30 @@ describe("CommentService", () => { expect(applyEdits(original, edits)).toBe(`<% render "thing" %>`) }) + it("uncomments a multiline ERB comment tag selection back to a plain tag", () => { + const original = `<%#\n a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(0, 2)) + + expect(applyEdits(original, edits)).toBe(`<%\n a\n%>`) + }) + + it("uncomments a commented Ruby line inside a multiline ERB tag", () => { + const original = `<%\n # a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`<%\n a\n%>`) + }) + + it("uncomments a Ruby line commented without a space after the hash", () => { + const original = `<%\n #a\n%>` + const document = createDocument(original) + const edits = service.toggleLineComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`<%\n a\n%>`) + }) + it("uncomments HTML wrapped in an HTML comment", () => { const original = `` const document = createDocument(original) @@ -992,6 +1096,61 @@ describe("CommentService", () => { expect(uncommented).toBe(original) }) + it("round-trips an internal Ruby line inside a multiline ERB tag", () => { + const original = `<%\n a\n%>` + const document1 = createDocument(original) + const commented = applyEdits(original, service.toggleLineComment(document1, lineRange(1))) + + expect(commented).toBe(`<%\n # a\n%>`) + + const document2 = createDocument(commented) + const uncommented = applyEdits(commented, service.toggleLineComment(document2, lineRange(1))) + + expect(uncommented).toBe(original) + }) + + it("round-trips a multiline ERB tag selection including delimiters", () => { + const original = `<%\n a\n%>` + const range = lineRange(0, 2) + const document1 = createDocument(original) + const commented = applyEdits(original, service.toggleLineComment(document1, range)) + + expect(commented).toBe(`<%#\n # a\n# %>`) + + const document2 = createDocument(commented) + const uncommented = applyEdits(commented, service.toggleLineComment(document2, range)) + + expect(uncommented).toBe(original) + }) + + it("round-trips the opening line of a multiline ERB tag with trailing code", () => { + const original = `<% x = [1,\n 2,\n] %>` + const range = lineRange(0) + const document1 = createDocument(original) + const commented = applyEdits(original, service.toggleLineComment(document1, range)) + + expect(commented).toBe(`<%# x = [1,\n 2,\n] %>`) + + const document2 = createDocument(commented) + const uncommented = applyEdits(commented, service.toggleLineComment(document2, range)) + + expect(uncommented).toBe(original) + }) + + it("round-trips a selection spanning two multiline ERB tags", () => { + const original = `<%\n a\n%>\n<%\n b\n%>` + const range = lineRange(1, 4) + const document1 = createDocument(original) + const commented = applyEdits(original, service.toggleLineComment(document1, range)) + + expect(commented).toBe(`<%\n # a\n# %>\n<%#\n # b\n%>`) + + const document2 = createDocument(commented) + const uncommented = applyEdits(commented, service.toggleLineComment(document2, range)) + + expect(uncommented).toBe(original) + }) + it("round-trips a real-world ERB template", () => { const original = dedent` <% @records.each do |record| %> @@ -1065,6 +1224,30 @@ describe("CommentService", () => { const startEdit = edits.find(edit => edit.newText.includes("<% if false %>")) expect(startEdit?.newText).toBe(" <% if false %>\n") }) + + it("comments an internal Ruby line inside a multiline ERB tag", () => { + const original = `<%\n a\n%>` + const document = createDocument(original) + const edits = service.toggleBlockComment(document, lineRange(1)) + + expect(applyEdits(original, edits)).toBe(`<%\n # a\n%>`) + }) + + it("comments multiple internal Ruby lines inside a multiline ERB tag", () => { + const original = `<%\n a\n b\n%>` + const document = createDocument(original) + const edits = service.toggleBlockComment(document, lineRange(1, 2)) + + expect(applyEdits(original, edits)).toBe(`<%\n # a\n # b\n%>`) + }) + + it("expands a selection ending inside a multiline ERB tag to wrap the whole tag", () => { + const original = `
hi
\n<%\n a\n%>\n` + const document = createDocument(original) + const edits = service.toggleBlockComment(document, lineRange(0, 2)) + + expect(applyEdits(original, edits)).toBe(`<% if false %>\n
hi
\n<%\n a\n%>\n<% end %>\n`) + }) }) describe("round-trip", () => { @@ -1089,6 +1272,19 @@ describe("CommentService", () => { <%= render "thing" %> `.trim()) }) + + it("round-trips an internal Ruby line inside a multiline ERB tag", () => { + const original = `<%\n a\n%>` + const document1 = createDocument(original) + const commented = applyEdits(original, service.toggleBlockComment(document1, lineRange(1))) + + expect(commented).toBe(`<%\n # a\n%>`) + + const document2 = createDocument(commented) + const uncommented = applyEdits(commented, service.toggleBlockComment(document2, lineRange(1))) + + expect(uncommented).toBe(original) + }) }) describe("unwrapping", () => {