Line 1
+Line 2
+diff --git a/javascript/packages/language-server/src/inlay_hint_service.ts b/javascript/packages/language-server/src/inlay_hint_service.ts new file mode 100644 index 000000000..2f40d9389 --- /dev/null +++ b/javascript/packages/language-server/src/inlay_hint_service.ts @@ -0,0 +1,178 @@ +import { InlayHint, InlayHintKind } from "vscode-languageserver/node" +import { TextDocument } from "vscode-languageserver-textdocument" + +import { Visitor, isHTMLOpenTagNode, isLiteralNode } from "@herb-tools/core" +import { ParserService } from "./parser_service" +import { lspPosition } from "./range_utils" + +import type { + ERBEndNode, + ERBIfNode, + ERBUnlessNode, + ERBBlockNode, + ERBCaseNode, + ERBCaseMatchNode, + ERBWhileNode, + ERBUntilNode, + ERBForNode, + ERBBeginNode, + HTMLElementNode, + HTMLOpenTagNode, + HTMLAttributeNode, + Node, +} from "@herb-tools/core" + +type ERBNodeWithEnd = ERBIfNode | ERBUnlessNode | ERBBlockNode | ERBCaseNode | ERBCaseMatchNode | ERBWhileNode | ERBUntilNode | ERBForNode | ERBBeginNode + +function labelForERBNode(node: ERBNodeWithEnd): string | null { + const content = node.content?.value?.trim() + + if (!content) return null + + return `# ${content}` +} + +function findAttributeValue(openTag: HTMLOpenTagNode, attributeName: string): string | null { + for (const child of openTag.children) { + if (child.type !== "AST_HTML_ATTRIBUTE_NODE") continue + + const attrNode = child as unknown as HTMLAttributeNode + if (!attrNode.name || !attrNode.value) continue + + const nameStr = attrNode.name.children + .filter(isLiteralNode) + .map(n => n.content) + .join("") + + if (nameStr !== attributeName) continue + + const valueStr = attrNode.value.children + .filter(isLiteralNode) + .map(n => n.content) + .join("") + + return valueStr + } + + return null +} + +function labelForHTMLElement(node: HTMLElementNode): string | null { + if (!node.open_tag || !isHTMLOpenTagNode(node.open_tag)) return null + + const id = findAttributeValue(node.open_tag, "id") + if (id) return `` + + const className = findAttributeValue(node.open_tag, "class") + if (className) return `` + + return null +} + +export class InlayHintCollector extends Visitor { + public hints: InlayHint[] = [] + + private addERBEndNodeHint(node: ERBNodeWithEnd): void { + const endNode: ERBEndNode | null = node.end_node + if (!endNode?.tag_closing) return + + const label = labelForERBNode(node) + if (!label) return + + const endLine = endNode.location.start.line + const nodeLine = node.location.start.line + + if (endLine - nodeLine < 2) return + + this.hints.push({ + position: lspPosition(endNode.tag_closing.location.end), + label: ` ${label}`, + kind: InlayHintKind.Parameter, + paddingLeft: true, + }) + } + + visitHTMLElementNode(node: HTMLElementNode): void { + if (node.close_tag && node.open_tag) { + const endLine = node.close_tag.location.start.line + const startLine = node.open_tag.location.start.line + + if (endLine - startLine >= 2) { + const label = labelForHTMLElement(node) + + if (label) { + this.hints.push({ + position: lspPosition(node.close_tag.location.end), + label: ` ${label}`, + kind: InlayHintKind.Parameter, + paddingLeft: true, + }) + } + } + } + + this.visitChildNodes(node) + } + + visitERBIfNode(node: ERBIfNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBUnlessNode(node: ERBUnlessNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBBlockNode(node: ERBBlockNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBCaseNode(node: ERBCaseNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBCaseMatchNode(node: ERBCaseMatchNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBWhileNode(node: ERBWhileNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBUntilNode(node: ERBUntilNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBForNode(node: ERBForNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } + + visitERBBeginNode(node: ERBBeginNode): void { + this.addERBEndNodeHint(node) + this.visitChildNodes(node) + } +} + +export class InlayHintService { + private parserService: ParserService + + constructor(parserService: ParserService) { + this.parserService = parserService + } + + getInlayHints(textDocument: TextDocument): InlayHint[] { + const parseResult = this.parserService.parseDocument(textDocument) + const collector = new InlayHintCollector() + + collector.visit(parseResult.document) + + return collector.hints + } +} diff --git a/javascript/packages/language-server/src/server.ts b/javascript/packages/language-server/src/server.ts index 787b79966..527a34731 100644 --- a/javascript/packages/language-server/src/server.ts +++ b/javascript/packages/language-server/src/server.ts @@ -14,6 +14,7 @@ import { CodeActionKind, FoldingRangeParams, DocumentHighlightParams, + InlayHintParams, TextDocumentIdentifier, Range, } from "vscode-languageserver/node" @@ -59,6 +60,7 @@ export class Server { }, foldingRangeProvider: true, documentHighlightProvider: true, + inlayHintProvider: true, }, } @@ -198,6 +200,14 @@ export class Server { return this.service.foldingRangeService.getFoldingRanges(document) }) + this.connection.languages.inlayHint.on((params: InlayHintParams) => { + const document = this.service.documentService.get(params.textDocument.uri) + + if (!document) return [] + + return this.service.inlayHintService.getInlayHints(document) + }) + this.connection.onRequest('herb/toggleLineComment', (params: { textDocument: TextDocumentIdentifier, range: Range }) => { const document = this.service.documentService.get(params.textDocument.uri) diff --git a/javascript/packages/language-server/src/service.ts b/javascript/packages/language-server/src/service.ts index 73c0028b9..11d496435 100644 --- a/javascript/packages/language-server/src/service.ts +++ b/javascript/packages/language-server/src/service.ts @@ -14,6 +14,7 @@ import { CodeActionService } from "./code_action_service" import { DocumentSaveService } from "./document_save_service" import { FoldingRangeService } from "./folding_range_service" import { DocumentHighlightService } from "./document_highlight_service" +import { InlayHintService } from "./inlay_hint_service" import { CommentService } from "./comment_service" import { version } from "../package.json" @@ -35,6 +36,7 @@ export class Service { documentSaveService: DocumentSaveService foldingRangeService: FoldingRangeService documentHighlightService: DocumentHighlightService + inlayHintService: InlayHintService commentService: CommentService constructor(connection: Connection, params: InitializeParams) { @@ -52,6 +54,7 @@ export class Service { this.documentSaveService = new DocumentSaveService(this.connection, this.settings, this.autofixService, this.formattingService) this.foldingRangeService = new FoldingRangeService(this.parserService) this.documentHighlightService = new DocumentHighlightService(this.parserService) + this.inlayHintService = new InlayHintService(this.parserService) this.commentService = new CommentService(this.parserService) if (params.initializationOptions) { diff --git a/javascript/packages/language-server/test/inlay_hint_service.test.ts b/javascript/packages/language-server/test/inlay_hint_service.test.ts new file mode 100644 index 000000000..e54653284 --- /dev/null +++ b/javascript/packages/language-server/test/inlay_hint_service.test.ts @@ -0,0 +1,304 @@ +import dedent from "dedent" + +import { describe, it, expect, beforeAll } from "vitest" +import { InlayHintKind } from "vscode-languageserver/node" +import { TextDocument } from "vscode-languageserver-textdocument" + +import { InlayHintService } from "../src/inlay_hint_service" +import { ParserService } from "../src/parser_service" +import { Herb } from "@herb-tools/node-wasm" + +describe("InlayHintService", () => { + let parserService: ParserService + let service: InlayHintService + + beforeAll(async () => { + await Herb.load() + parserService = new ParserService() + service = new InlayHintService(parserService) + }) + + function createDocument(content: string): TextDocument { + return TextDocument.create("file:///test.html.erb", "erb", 1, content) + } + + function getHints(content: string) { + return service.getInlayHints(createDocument(content)) + } + + describe("ERB if/end", () => { + it("shows hint on end tag for multi-line if block", () => { + const hints = getHints(dedent` + <% if user.admin? %> +
Admin
+Content
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # if user.admin?") + expect(hints[0].kind).toBe(InlayHintKind.Parameter) + expect(hints[0].paddingLeft).toBe(true) + }) + + it("does not show hint when if block spans 2 lines or fewer", () => { + const hints = getHints(dedent` + <% if user.admin? %> + <% end %> + `) + + expect(hints).toHaveLength(0) + }) + }) + + describe("ERB unless/end", () => { + it("shows hint on end tag for multi-line unless block", () => { + const hints = getHints(dedent` + <% unless user.admin? %> +Line 1
+Line 2
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # unless user.admin?") + }) + + it("does not show hint for short unless block", () => { + const hints = getHints(dedent` + <% unless user.admin? %> + <% end %> + `) + + expect(hints).toHaveLength(0) + }) + }) + + describe("ERB block/end (each, do)", () => { + it("shows hint on end tag for multi-line block", () => { + const hints = getHints(dedent` + <% items.each do |item| %> +Line 1
+Line 2
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # items.each do |item|") + }) + + it("does not show hint for short block", () => { + const hints = getHints(dedent` + <% items.each do |item| %> + <% end %> + `) + + expect(hints).toHaveLength(0) + }) + }) + + describe("ERB case/end", () => { + it("shows hint on end tag for multi-line case block", () => { + const hints = getHints(dedent` + <% case role %> + <% when "admin" %> +Admin
+ <% when "user" %> +User
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # case role") + }) + }) + + describe("ERB case/in (pattern matching)", () => { + it("shows hint on end tag for multi-line case/in block", () => { + const hints = getHints(dedent` + <% case value %> + <% in String %> +String
+ <% in Integer %> +Integer
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # case value") + }) + }) + + describe("ERB while/end", () => { + it("shows hint on end tag for multi-line while block", () => { + const hints = getHints(dedent` + <% while counter < 10 %> +Line 1
+Line 2
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # while counter < 10") + }) + }) + + describe("ERB until/end", () => { + it("shows hint on end tag for multi-line until block", () => { + const hints = getHints(dedent` + <% until counter >= 10 %> +Line 1
+Line 2
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # until counter >= 10") + }) + }) + + describe("ERB for/end", () => { + it("shows hint on end tag for multi-line for block", () => { + const hints = getHints(dedent` + <% for i in 1..10 %> +Line 1
+Line 2
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # for i in 1..10") + }) + }) + + describe("ERB begin/end", () => { + it("shows hint on end tag for multi-line begin block", () => { + const hints = getHints(dedent` + <% begin %> + <%= risky_operation %> +More content
+ <% rescue StandardError => e %> +Error
+ <% end %> + `) + + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe(" # begin") + }) + }) + + describe("HTML elements with id", () => { + it("shows hint with id on closing tag for multi-line element", () => { + const hints = getHints(dedent` +Line 1
+Line 2
+Line 1
+Line 2
+Line 1
+Line 2
+Line 1
+Line 2
+Line 1
+Line 2
+Line 1
+Line 2
+