From 289585010a7346320bae771d8020bab06f65d935 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:13:31 +0530 Subject: [PATCH 1/2] fix(lsp): hover tooltip code highlighting --- src/cm/baseExtensions.ts | 11 +- src/cm/lsp/clientManager.ts | 10 +- src/cm/lsp/tooltipExtensions.ts | 203 +++++++++++++++++++++++++++++++- src/cm/lsp/types.ts | 2 + 4 files changed, 221 insertions(+), 5 deletions(-) diff --git a/src/cm/baseExtensions.ts b/src/cm/baseExtensions.ts index cc0f1a9f4..bd1052460 100644 --- a/src/cm/baseExtensions.ts +++ b/src/cm/baseExtensions.ts @@ -1,4 +1,8 @@ -import { closeBrackets, completionKeymap } from "@codemirror/autocomplete"; +import { + acceptCompletion, + closeBrackets, + completionKeymap, +} from "@codemirror/autocomplete"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { bracketMatching, @@ -9,7 +13,7 @@ import { } from "@codemirror/language"; import { highlightSelectionMatches } from "@codemirror/search"; import type { Extension } from "@codemirror/state"; -import { EditorState } from "@codemirror/state"; +import { EditorState, Prec } from "@codemirror/state"; import { crosshairCursor, drawSelection, @@ -67,6 +71,9 @@ export default function createBaseExtensions( if (enableHighlightSelectionMatches) { extensions.push(highlightSelectionMatches()); } + extensions.push( + Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])), + ); extensions.push( keymap.of([...completionKeymap, ...defaultKeymap, ...historyKeymap]), ); diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index db96d2485..6660191c9 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -21,7 +21,11 @@ import { inlayHintsExtension } from "./inlayHints"; import { acodeRenameKeymap } from "./rename"; import { selectRuntimeProvider } from "./runtimeProviders"; import serverRegistry from "./serverRegistry"; -import { hoverTooltips, signatureHelp } from "./tooltipExtensions"; +import { + hoverTooltips, + resolveLspHoverHighlightLanguage, + signatureHelp, +} from "./tooltipExtensions"; import { createTransport } from "./transport"; import type { BuiltinExtensionsConfig, @@ -798,6 +802,10 @@ export class LspClientManager { clientConfig.timeout = server.startupTimeout; } + if (!clientConfig.highlightLanguage) { + clientConfig.highlightLanguage = resolveLspHoverHighlightLanguage; + } + let transportHandle: TransportHandle | undefined; let client: ExtendedLSPClient | undefined; let runtimeConnection: LspRuntimeConnection | undefined; diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index db09cc53c..0547a4ffe 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -1,8 +1,10 @@ import { highlightingFor, type Language, + LanguageDescription, language as languageFacet, } from "@codemirror/language"; +import { languages } from "@codemirror/language-data"; import { LSPPlugin } from "@codemirror/lsp-client"; import { type Extension, @@ -35,6 +37,7 @@ import type { MarkedString, MarkupContent, } from "vscode-languageserver-types"; +import { getMode, getModeForPath, type Mode } from "../modelist"; interface LspClientInternals { config?: { @@ -45,6 +48,201 @@ interface LspClientInternals { const SIGNATURE_TRIGGER_DELAY = 120; const SIGNATURE_RETRIGGER_DELAY = 250; +const hoverLanguageLoads = new Map>(); +const pluginHoverLanguages = new Map(); +const pluginHoverLanguageLoads = new Map>(); +const MARKDOWN_FENCE = /^ {0,3}(?:`{3,}|~{3,})[ \t]*([^\n]*)$/gm; + +function normalizeLanguageName(value: string): string { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function matchingModeName(a: string, b: string): boolean { + const normalizedA = normalizeLanguageName(a); + const normalizedB = normalizeLanguageName(b); + if (!normalizedA || !normalizedB) return false; + if (normalizedA === normalizedB) return true; + + const languageA = findLanguageDescription(normalizedA); + const languageB = findLanguageDescription(normalizedB); + return !!languageA && languageA === languageB; +} + +function getLanguageCandidates(language: string): string[] { + const normalized = normalizeLanguageName(language); + if (!normalized) return []; + + const candidates = new Set([normalized]); + if (normalized.endsWith("react")) { + const withoutReact = normalized.slice(0, -"react".length); + if (withoutReact) candidates.add(withoutReact); + } + return [...candidates]; +} + +function findLanguageDescription(language: string): LanguageDescription | null { + for (const candidate of getLanguageCandidates(language)) { + const byName = LanguageDescription.matchLanguageName( + languages, + candidate, + false, + ); + if (byName) return byName; + + const byExtension = LanguageDescription.matchFilename( + languages, + `file.${candidate}`, + ); + if (byExtension) return byExtension; + } + return null; +} + +function findPluginMode(language: string): Mode | null { + for (const candidate of getLanguageCandidates(language)) { + const byName = getMode(candidate); + if (byName) return byName; + + const byExtension = getModeForPath(`file.${candidate}`); + if (byExtension && byExtension.name !== "text") return byExtension; + } + return null; +} + +function extractLanguage(value: unknown): Language | null { + if (!value) return null; + if (Array.isArray(value)) { + for (const item of value) { + const language = extractLanguage(item); + if (language) return language; + } + return null; + } + if (typeof value !== "object") return null; + + const record = value as Record; + const language = record.language; + if (language && typeof language === "object" && "parser" in language) { + return language as Language; + } + return "parser" in record ? (value as Language) : null; +} + +function startPluginLanguageLoad(mode: Mode): Promise | null { + const cached = pluginHoverLanguageLoads.get(mode); + if (cached) return cached; + + const loader = mode.getExtension(); + if (!loader) return null; + + const load = Promise.resolve() + .then(() => loader()) + .then((extension) => { + const language = extractLanguage(extension); + if (language) pluginHoverLanguages.set(mode, language); + return language; + }) + .catch(() => null); + pluginHoverLanguageLoads.set(mode, load); + return load; +} + +export function resolveLspHoverHighlightLanguage( + language: string, +): Language | null { + const description = findLanguageDescription(language); + if (description) { + if (description.support) return description.support.language; + + const key = description.name.toLowerCase(); + if (!hoverLanguageLoads.has(key)) { + hoverLanguageLoads.set( + key, + description + .load() + .then((support) => support.language) + .catch(() => null), + ); + } + return null; + } + + const mode = findPluginMode(language); + if (!mode) return null; + const loaded = pluginHoverLanguages.get(mode); + if (loaded) return loaded; + startPluginLanguageLoad(mode); + return null; +} + +export async function loadLspHoverHighlightLanguage( + language: string, +): Promise { + const description = findLanguageDescription(language); + if (description) { + if (description.support) return description.support.language; + + const key = description.name.toLowerCase(); + let load = hoverLanguageLoads.get(key); + if (!load) { + load = description + .load() + .then((support) => support.language) + .catch(() => null); + hoverLanguageLoads.set(key, load); + } + return load; + } + + const mode = findPluginMode(language); + if (!mode) return null; + return pluginHoverLanguages.get(mode) || startPluginLanguageLoad(mode); +} + +function getFenceLanguage(info: string): string { + const trimmed = info.trim(); + if (!trimmed) return ""; + + if (trimmed.startsWith("{")) { + return trimmed.match(/\.([\w+#.-]+)/)?.[1] || ""; + } + return trimmed.split(/\s+/, 1)[0] || ""; +} + +function collectMarkdownLanguages(markdown: string, result: Set): void { + MARKDOWN_FENCE.lastIndex = 0; + for (let match = MARKDOWN_FENCE.exec(markdown); match; match = MARKDOWN_FENCE.exec(markdown)) { + const language = getFenceLanguage(match[1] || ""); + if (language) result.add(language); + } +} + +function collectHoverLanguages( + contents: Hover["contents"], + result = new Set(), +): Set { + if (Array.isArray(contents)) { + contents.forEach((content) => collectHoverLanguages(content, result)); + } else if (typeof contents === "string") { + collectMarkdownLanguages(contents, result); + } else if ("language" in contents) { + if (contents.language) result.add(contents.language); + } else if (contents.kind === "markdown") { + collectMarkdownLanguages(contents.value, result); + } + return result; +} + +async function loadHoverContentLanguages(contents: Hover["contents"]): Promise { + const languageTags = collectHoverLanguages(contents); + await Promise.all( + Array.from(languageTags, (language) => + loadLspHoverHighlightLanguage(language), + ), + ); +} function fromPosition( doc: EditorView["state"]["doc"], @@ -83,7 +281,7 @@ function renderCode(plugin: LSPPlugin, code: MarkedString): string { if (!lang) { const viewLang = plugin.view.state.facet(languageFacet); - if (viewLang && (!language || viewLang.name === language)) { + if (viewLang && (!language || matchingModeName(viewLang.name, language))) { lang = viewLang; } } @@ -167,8 +365,9 @@ function lspTooltipSource( const plugin = LSPPlugin.get(view); if (!plugin) return Promise.resolve(null); - return hoverRequest(plugin, pos).then((result) => { + return hoverRequest(plugin, pos).then(async (result) => { if (!result) return null; + await loadHoverContentLanguages(result.contents); return { pos: result.range diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index c28f3d1bb..631444a2e 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -6,6 +6,7 @@ import type { Workspace, WorkspaceFile, } from "@codemirror/lsp-client"; +import type { Language } from "@codemirror/language"; import type { ChangeSet, Extension, MapMode, Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; @@ -234,6 +235,7 @@ export interface AcodeClientConfig { workspace?: (client: LSPClient) => Workspace; rootUri?: string; timeout?: number; + highlightLanguage?: (name: string) => Language | null; } export interface LanguageResolverContext { From bcc538516994073fba42df1f142d08455a4d1cca Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:27:01 +0530 Subject: [PATCH 2/2] use weakmap --- src/cm/lsp/tooltipExtensions.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index 0547a4ffe..5e54537bc 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -49,9 +49,11 @@ interface LspClientInternals { const SIGNATURE_TRIGGER_DELAY = 120; const SIGNATURE_RETRIGGER_DELAY = 250; const hoverLanguageLoads = new Map>(); -const pluginHoverLanguages = new Map(); -const pluginHoverLanguageLoads = new Map>(); -const MARKDOWN_FENCE = /^ {0,3}(?:`{3,}|~{3,})[ \t]*([^\n]*)$/gm; +const pluginHoverLanguages = new WeakMap(); +const pluginHoverLanguageLoads = new WeakMap< + Mode, + Promise +>(); function normalizeLanguageName(value: string): string { return String(value ?? "") @@ -212,8 +214,12 @@ function getFenceLanguage(info: string): string { } function collectMarkdownLanguages(markdown: string, result: Set): void { - MARKDOWN_FENCE.lastIndex = 0; - for (let match = MARKDOWN_FENCE.exec(markdown); match; match = MARKDOWN_FENCE.exec(markdown)) { + const fencePattern = /^ {0,3}(?:`{3,}|~{3,})[ \t]*([^\n]*)$/gm; + for ( + let match = fencePattern.exec(markdown); + match; + match = fencePattern.exec(markdown) + ) { const language = getFenceLanguage(match[1] || ""); if (language) result.add(language); }