Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/cm/baseExtensions.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -67,6 +71,9 @@ export default function createBaseExtensions(
if (enableHighlightSelectionMatches) {
extensions.push(highlightSelectionMatches());
}
extensions.push(
Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])),
);
Comment thread
bajrangCoder marked this conversation as resolved.
extensions.push(
keymap.of([...completionKeymap, ...defaultKeymap, ...historyKeymap]),
);
Expand Down
10 changes: 9 additions & 1 deletion src/cm/lsp/clientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
209 changes: 207 additions & 2 deletions src/cm/lsp/tooltipExtensions.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -35,6 +37,7 @@ import type {
MarkedString,
MarkupContent,
} from "vscode-languageserver-types";
import { getMode, getModeForPath, type Mode } from "../modelist";

interface LspClientInternals {
config?: {
Expand All @@ -45,6 +48,207 @@ interface LspClientInternals {

const SIGNATURE_TRIGGER_DELAY = 120;
const SIGNATURE_RETRIGGER_DELAY = 250;
const hoverLanguageLoads = new Map<string, Promise<Language | null>>();
const pluginHoverLanguages = new WeakMap<Mode, Language>();
const pluginHoverLanguageLoads = new WeakMap<
Mode,
Promise<Language | null>
>();

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<string, unknown>;
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<Language | null> | 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<Language | null> {
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<string>): void {
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);
}
}

function collectHoverLanguages(
contents: Hover["contents"],
result = new Set<string>(),
): Set<string> {
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<void> {
const languageTags = collectHoverLanguages(contents);
await Promise.all(
Array.from(languageTags, (language) =>
loadLspHoverHighlightLanguage(language),
),
);
}

function fromPosition(
doc: EditorView["state"]["doc"],
Expand Down Expand Up @@ -83,7 +287,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;
}
}
Expand Down Expand Up @@ -167,8 +371,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
Expand Down
2 changes: 2 additions & 0 deletions src/cm/lsp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -234,6 +235,7 @@ export interface AcodeClientConfig {
workspace?: (client: LSPClient) => Workspace;
rootUri?: string;
timeout?: number;
highlightLanguage?: (name: string) => Language | null;
}

export interface LanguageResolverContext {
Expand Down