Skip to content

Commit

Permalink
Merge pull request #1561 from DanielXMoore/lsp-completion-details
Browse files Browse the repository at this point in the history
LSP completions show details and documentation
  • Loading branch information
edemaine authored Nov 2, 2024
2 parents 1cc4b46 + ea4b9cd commit 3e73ff8
Show file tree
Hide file tree
Showing 2 changed files with 232 additions and 9 deletions.
162 changes: 162 additions & 0 deletions lsp/source/lib/textRendering.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type ts from 'typescript'
type SymbolDisplayPart = ts.server.protocol.SymbolDisplayPart
type FileSpan = ts.server.protocol.FileSpan
type JSDocTagInfo = ts.server.protocol.JSDocTagInfo

// Based on https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/src/languageFeatures/util/textRendering.ts

export function asPlainText(parts: SymbolDisplayPart[] | string): string {
if (typeof parts === "string") return parts
return parts.map(part => part.text).join('')
}

export function asPlainTextWithLinks(parts: SymbolDisplayPart[] | string | undefined): string {
if (!parts) return ''
if (typeof parts === 'string') return parts
let out = ""
let currentLink: {
name?: string
target?: FileSpan
text?: string
linkcode: boolean
} | undefined
for (const part of parts) {
switch (part.kind) {
case "link":
if (currentLink) {
if (currentLink.target) {
//const file = filePathConverter.toResource(currentLink.target.file)
const args = {
//file: { ...file.toJSON(), $mid: undefined },
file: currentLink.target.file,
position: {
lineNumber: currentLink.target.start.line - 1,
column: currentLink.target.start.offset - 1
}
}
const command = "command:_typescript.openJsDocLink?" +
encodeURIComponent(JSON.stringify([args]))
const linkText = currentLink.text || escapeBackTicks(currentLink.name ?? "")
out += `[${currentLink.linkcode ? '`' + linkText + '`' : linkText}](${command})`
} else {
const text = currentLink.text ?? currentLink.name
if (text) {
if (/^https?:/.test(text)) {
const subparts = text.split(' ')
if (subparts.length === 1 && !currentLink.linkcode) {
out += `<${subparts[0]}>`
} else {
const linkText = subparts.length > 1
? subparts.slice(1).join(' ')
: subparts[0]
out += `[${currentLink.linkcode ? '`' + escapeBackTicks(linkText) + '`' : linkText}](${subparts[0]})`
}
} else {
out += escapeBackTicks(text)
}
}
}
} else {
currentLink = {
linkcode: part.text === "{@linkcode "
}
}
currentLink = undefined
break
case "linkName":
if (currentLink) {
currentLink.name = part.text
// @ts-ignore Proto.JSDocLinkDisplayPart
currentLink.target = part.target
}
break
case "linkText":
if (currentLink) currentLink.text = part.text
break
default:
out += part.text
}
}
return out
}

function escapeBackTicks(text: string): string {
return text.replace(/`/g, '\\$&');
}

export function tagsToMarkdown(tags: JSDocTagInfo[]): string {
return tags.map(getTagDocumentation).join(' \n\n')
}

function getTagDocumentation(tag: JSDocTagInfo): string | undefined {
switch (tag.name) {
case "augments":
case "extends":
case "param":
case "template":
const body = getTagBody(tag)
if (body?.length === 3) {
const [, param, doc] = body
const label = `*@${tag.name}* \`${param}\``
if (!doc) return label
return label + (doc.match(/\r\n|\n/g) ? ' \n' + doc : ` \u2014 ${doc}`)
}
break
case "return":
case "returns":
if (!tag.text?.length) return undefined
break
}
const label = `*@${tag.name}*`
const text = getTagBodyText(tag)
if (!text) return label
return label + (text.match(/\r\n|\n/g) ? ' \n' + text : ` \u2014 ${text}`)
}

function getTagBody(tag: JSDocTagInfo): Array<string> | undefined {
if (tag.name === "template") {
const parts = tag.text
if (parts && typeof parts !== "string") {
const params = parts
.filter(p => p.kind === "typeParameterName")
.map(p => p.text)
.join(", ")
const docs = parts
.filter(p => p.kind === "text")
.map(p => asPlainTextWithLinks(p.text.replace(/^\s*-?\s*/, "")))
.join(", ")
return params ? ["", params, docs] : undefined
}
}
return asPlainTextWithLinks(tag.text).split(/^(\S+)\s*-?\s*/)
}

function getTagBodyText(tag: JSDocTagInfo): string | undefined {
if (!tag.text) return

function makeCodeblock(text: string): string {
if (/^\s*[~`]{3}/m.test(text)) return text
return '```\n' + text + '\n```';
}

let text = asPlainTextWithLinks(tag.text)
switch (tag.name) {
case "example":
text = asPlainText(tag.text)
const captionTagMatches = text.match(/<caption>(.*?)<\/caption>\s*(\r\n|\n)/)
if (captionTagMatches && captionTagMatches.index === 0) {
return captionTagMatches[1] + "\n" +
makeCodeblock(text.slice(captionTagMatches[0].length))
} else {
return makeCodeblock(text)
}
case "author":
const emailMatch = text.match(/(.+)\s<([-.\w]+@[-.\w]+)>/)
if (!emailMatch) return text
return `${emailMatch[1]} ${emailMatch[2]}`
case "default":
return makeCodeblock(text)
default:
return text
}
}
79 changes: 70 additions & 9 deletions lsp/source/server.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import {
} from 'vscode-languageserver/node';

import {
TextDocument
TextDocument,
type Position
} from 'vscode-languageserver-textdocument';
import TSService from './lib/typescript-service.mjs';
import * as Previewer from "./lib/previewer.mjs";
import { convertNavTree, forwardMap, getCompletionItemKind, convertDiagnostic, remapPosition, parseKindModifier, logTiming, WithResolvers, withResolvers } from './lib/util.mjs';
import { asPlainTextWithLinks, tagsToMarkdown } from './lib/textRendering.mjs';
import assert from "assert"
import path from "node:path"
import ts, {
Expand Down Expand Up @@ -274,14 +276,13 @@ connection.onCompletion(async ({ textDocument, position, context: _context }) =>
const p = document.offsetAt(position)
const completions = service.getCompletionsAtPosition(sourcePath, p, completionOptions)
if (!completions) return
return convertCompletions(completions, document)
return convertCompletions(completions, document, sourcePath, position)
}

// need to sourcemap the line/columns
const meta = service.host.getMeta(sourcePath)
if (!meta) return
const sourcemapLines = meta.sourcemapLines
const transpiledDoc = meta.transpiledDoc
const { sourcemapLines, transpiledDoc } = meta
if (!transpiledDoc) return

// Map input hover position into output TS position
Expand All @@ -296,12 +297,68 @@ connection.onCompletion(async ({ textDocument, position, context: _context }) =>
const completions = service.getCompletionsAtPosition(transpiledPath, p, completionOptions)
if (!completions) return;

return convertCompletions(completions, transpiledDoc, sourcemapLines)
return convertCompletions(completions, transpiledDoc, sourcePath, position, sourcemapLines)
});

// TODO
connection.onCompletionResolve((item) => {
return item;
type CompletionItemData = {
sourcePath: string
position: Position
name: string
source: string | undefined
data: ts.CompletionEntryData | undefined
}

connection.onCompletionResolve(async (item) => {
let { sourcePath, position, name, source, data } =
item.data as CompletionItemData
const service = await ensureServiceForSourcePath(sourcePath)
if (!service) return item

let document
if (sourcePath.match(tsSuffix)) { // non-transpiled
document = documents.get(pathToFileURL(sourcePath).toString())
assert(document)
} else {
// use transpiled doc; forward source mapping already done
const meta = service.host.getMeta(sourcePath)
if (!meta) return item
const { transpiledDoc } = meta
if (!transpiledDoc) return item
document = transpiledDoc
sourcePath = documentToSourcePath(transpiledDoc)
}
const p = document.offsetAt(position)

const detail = service.getCompletionEntryDetails(sourcePath, p, name, undefined, source, undefined, data)
if (!detail) return item

// getDetails from https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/src/languageFeatures/completions.ts
const details = []
for (const action of detail.codeActions ?? []) {
details.push(action.description)
}
details.push(asPlainTextWithLinks(detail.displayParts))
item.detail = details.join("\n\n")

// getDocumentation from https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/src/languageFeatures/completions.ts
const documentations = []
if (detail.documentation) {
documentations.push(asPlainTextWithLinks(detail.documentation))
}
if (detail.tags) {
documentations.push(tagsToMarkdown(detail.tags))
}
if (documentations.length) {
item.documentation = {
kind: "markdown",
value: documentations.join("\n\n"),
// @ts-ignore
baseUri: document.uri,
isTrusted: { enabledCommands: ["_typescript.openJsDocLink"] },
}
}

return item
});

connection.onDefinition(async ({ textDocument, position }) => {
Expand Down Expand Up @@ -670,7 +727,7 @@ function documentToSourcePath(textDocument: TextDocumentIdentifier) {
return fileURLToPath(textDocument.uri);
}

function convertCompletions(completions: ts.CompletionInfo, document: TextDocument, sourcemapLines?: any): CompletionItem[] {
function convertCompletions(completions: ts.CompletionInfo, document: TextDocument, sourcePath: string, position: Position, sourcemapLines?: any): CompletionItem[] {
// Partial simulation of MyCompletionItem in
// https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/src/languageFeatures/completions.ts
const { entries } = completions;
Expand All @@ -680,6 +737,10 @@ function convertCompletions(completions: ts.CompletionInfo, document: TextDocume
const item: CompletionItem = {
label: entry.name || (entry.insertText ?? ''),
kind: getCompletionItemKind(entry.kind),
data: {
sourcePath, position,
name: entry.name, source: entry.source, data: entry.data,
} satisfies CompletionItemData,
}

if (entry.sourceDisplay) {
Expand Down

0 comments on commit 3e73ff8

Please sign in to comment.