From 33490a1e4a075929b9fac863533c97fc7a2d88a6 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 16 May 2026 13:31:53 -0700 Subject: [PATCH] feat: add hover provider (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+hover didn't display a preview popup because the language server only advertised definitionProvider and workspaceSymbolProvider — VSCode's hover preview is driven by the hover provider API. Add `hoverProvider: true` to server capabilities and implement an onHover handler that reuses the selector-extraction (findSelector) and symbol-lookup (findSymbols) logic. The hover renders the first matching rule's CSS source as a fenced markdown code block, truncated to ~20 lines to keep the popover compact. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/core/findHover.ts | 36 ++++++++++++++++++++ server/src/server.ts | 28 ++++++++++++++++ tests/src/findHover.test.ts | 65 ++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 server/src/core/findHover.ts create mode 100644 tests/src/findHover.test.ts diff --git a/server/src/core/findHover.ts b/server/src/core/findHover.ts new file mode 100644 index 0000000..cc788ea --- /dev/null +++ b/server/src/core/findHover.ts @@ -0,0 +1,36 @@ +import { Hover, MarkupKind } from "vscode-languageserver/node"; + +import { Selector, StylesheetMap } from "../types"; +import { findSymbols } from "./findDefinition"; + +const MAX_LINES = 20; + +export function findHover( + selector: Selector, + stylesheetMap: StylesheetMap, + options?: { peekVariables?: boolean } +): Hover | null { + const symbols = findSymbols(selector, stylesheetMap, options); + if (symbols.length === 0) { + return null; + } + + const symbol = symbols[0]; + const styleSheet = stylesheetMap[symbol.location.uri]; + if (!styleSheet) { + return null; + } + + const source = styleSheet.document.getText(symbol.location.range); + const lines = source.split(/\r?\n/); + const truncated = lines.length > MAX_LINES; + const snippet = (truncated ? lines.slice(0, MAX_LINES) : lines).join("\n"); + const suffix = truncated ? "\n/* … */" : ""; + + return { + contents: { + kind: MarkupKind.Markdown, + value: "```css\n" + snippet + suffix + "\n```", + }, + }; +} diff --git a/server/src/server.ts b/server/src/server.ts index 8d02eaa..0da637d 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -10,6 +10,7 @@ import { TextDocumentSyncKind, TextDocumentPositionParams, Definition, + Hover, InitializeParams, DidChangeConfigurationNotification, } from "vscode-languageserver/node"; @@ -22,6 +23,7 @@ import { findDefinition, isLanguageServiceSupported, } from "./core/findDefinition"; +import { findHover } from "./core/findHover"; import { create } from "./logger"; // Creates the LSP connection @@ -119,6 +121,7 @@ connection.onInitialize((params) => { change: TextDocumentSyncKind.Full, }, definitionProvider: true, + hoverProvider: true, workspaceSymbolProvider: true, }, }; @@ -233,6 +236,31 @@ connection.onDefinition( } ); +connection.onHover( + async ( + textDocumentPositon: TextDocumentPositionParams + ): Promise => { + const documentIdentifier = textDocumentPositon.textDocument; + const position = textDocumentPositon.position; + + const document = documents.get(documentIdentifier.uri); + + if (!document || !(await isValidPeekSource(document))) { + return null; + } + const settings = await getDocumentSettings(document.uri); + + const selector: Selector = findSelector(document, position, settings); + if (!selector) { + return null; + } + + return findHover(selector, styleSheets, { + peekVariables: settings.peekVariables, + }); + } +); + connection.onWorkspaceSymbol(async ({ query }) => { if (query.length < 2) return []; const selectors: Selector[] = [ diff --git a/tests/src/findHover.test.ts b/tests/src/findHover.test.ts new file mode 100644 index 0000000..95dc0d7 --- /dev/null +++ b/tests/src/findHover.test.ts @@ -0,0 +1,65 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { TextDocument as ServerTextDocument } from "vscode-languageserver"; + +import { findHover } from "../../server/out/core/findHover"; +import { create } from "../../server/out/logger"; +import type { StylesheetMap, Selector } from "../../server/src/types"; + +async function loadStylesheets(files: string[]): Promise { + const map: StylesheetMap = {}; + for (const file of files) { + const vscodeDoc = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, file) + ); + const text = vscodeDoc.getText(); + const serverDoc = ServerTextDocument.create( + vscodeDoc.uri.toString(), + vscodeDoc.languageId, + vscodeDoc.version, + text + ); + map[serverDoc.uri] = { document: serverDoc }; + } + return map; +} + +suite("findHover", () => { + create(console as any); + let map: StylesheetMap; + suiteSetup(async () => { + map = await loadStylesheets(["stylesheet.css", "example.scss"]); + }); + + test("returns markdown hover with css source for a class selector", () => { + const selector: Selector = { attribute: "class", value: "test" }; + const hover = findHover(selector, map); + assert.ok(hover, "expected a hover result"); + const contents = hover!.contents as { kind: string; value: string }; + assert.strictEqual(contents.kind, "markdown"); + assert.match(contents.value, /^```css\n/); + assert.match(contents.value, /\n```$/); + assert.ok( + contents.value.includes(".test"), + `expected hover to include the selector body, got: ${contents.value}` + ); + }); + + test("returns markdown hover with css source for an id selector", () => { + const selector: Selector = { attribute: "id", value: "testID" }; + const hover = findHover(selector, map); + assert.ok(hover, "expected a hover result"); + const contents = hover!.contents as { kind: string; value: string }; + assert.ok(contents.value.includes("#testID")); + assert.ok(contents.value.includes("color: green")); + }); + + test("returns null when no matching selector exists", () => { + const selector: Selector = { + attribute: "class", + value: "this-class-does-not-exist-anywhere", + }; + const hover = findHover(selector, map); + assert.strictEqual(hover, null); + }); +});