From 3f9ccc60d45554f22199e54fa708d01ab2cc5281 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Mon, 11 Mar 2024 06:42:47 +0800 Subject: [PATCH] refactor(typescript): split into multiple ServicePlugins (#83) --- packages/typescript-twoslash-queries/index.ts | 3 +- packages/typescript/directiveComment.ts | 85 ++ .../jsDoc.ts => docCommentTemplate.ts} | 62 +- packages/typescript/index.ts | 710 +------------- .../lib/configs/getFormatCodeSettings.ts | 5 +- .../lib/configs/getUserPreferences.ts | 2 +- .../typescript/lib/features/callHierarchy.ts | 113 --- .../lib/features/completions/basic.ts | 404 -------- .../features/completions/directiveComment.ts | 72 -- .../lib/features/completions/resolve.ts | 173 ---- .../typescript/lib/features/definition.ts | 19 - .../typescript/lib/features/diagnostics.ts | 137 --- .../lib/features/documentHighlight.ts | 34 - .../typescript/lib/features/documentSymbol.ts | 99 -- .../typescript/lib/features/fileReferences.ts | 17 - .../typescript/lib/features/fileRename.ts | 25 - .../typescript/lib/features/foldingRanges.ts | 65 -- .../typescript/lib/features/formatting.ts | 69 -- packages/typescript/lib/features/hover.ts | 46 - .../typescript/lib/features/implementation.ts | 18 - .../typescript/lib/features/inlayHints.ts | 37 - .../typescript/lib/features/prepareRename.ts | 27 - .../typescript/lib/features/references.ts | 33 - packages/typescript/lib/features/rename.ts | 149 --- .../lib/features/selectionRanges.ts | 36 - .../typescript/lib/features/signatureHelp.ts | 64 -- .../typescript/lib/features/typeDefinition.ts | 18 - .../lib/features/workspaceSymbol.ts | 66 -- .../codeAction.ts | 35 +- .../codeActionResolve.ts | 16 +- .../semanticTokens.ts | 16 +- .../lib/{ => semanticFeatures}/types.ts | 3 +- packages/typescript/lib/shared.ts | 4 + .../lib/syntacticLanguageService.ts | 40 + .../typescript/lib/utils/lspConverters.ts | 915 ++++++++++++++++++ packages/typescript/lib/utils/previewer.ts | 88 +- packages/typescript/lib/utils/transforms.ts | 91 -- packages/typescript/semantic.ts | 892 +++++++++++++++++ packages/typescript/syntactic.ts | 131 +++ 39 files changed, 2189 insertions(+), 2630 deletions(-) create mode 100644 packages/typescript/directiveComment.ts rename packages/typescript/{lib/features/completions/jsDoc.ts => docCommentTemplate.ts} (64%) delete mode 100644 packages/typescript/lib/features/callHierarchy.ts delete mode 100644 packages/typescript/lib/features/completions/basic.ts delete mode 100644 packages/typescript/lib/features/completions/directiveComment.ts delete mode 100644 packages/typescript/lib/features/completions/resolve.ts delete mode 100644 packages/typescript/lib/features/definition.ts delete mode 100644 packages/typescript/lib/features/diagnostics.ts delete mode 100644 packages/typescript/lib/features/documentHighlight.ts delete mode 100644 packages/typescript/lib/features/documentSymbol.ts delete mode 100644 packages/typescript/lib/features/fileReferences.ts delete mode 100644 packages/typescript/lib/features/fileRename.ts delete mode 100644 packages/typescript/lib/features/foldingRanges.ts delete mode 100644 packages/typescript/lib/features/formatting.ts delete mode 100644 packages/typescript/lib/features/hover.ts delete mode 100644 packages/typescript/lib/features/implementation.ts delete mode 100644 packages/typescript/lib/features/inlayHints.ts delete mode 100644 packages/typescript/lib/features/prepareRename.ts delete mode 100644 packages/typescript/lib/features/references.ts delete mode 100644 packages/typescript/lib/features/rename.ts delete mode 100644 packages/typescript/lib/features/selectionRanges.ts delete mode 100644 packages/typescript/lib/features/signatureHelp.ts delete mode 100644 packages/typescript/lib/features/typeDefinition.ts delete mode 100644 packages/typescript/lib/features/workspaceSymbol.ts rename packages/typescript/lib/{features => semanticFeatures}/codeAction.ts (88%) rename packages/typescript/lib/{features => semanticFeatures}/codeActionResolve.ts (82%) rename packages/typescript/lib/{features => semanticFeatures}/semanticTokens.ts (94%) rename packages/typescript/lib/{ => semanticFeatures}/types.ts (80%) create mode 100644 packages/typescript/lib/syntacticLanguageService.ts create mode 100644 packages/typescript/lib/utils/lspConverters.ts delete mode 100644 packages/typescript/lib/utils/transforms.ts create mode 100644 packages/typescript/semantic.ts create mode 100644 packages/typescript/syntactic.ts diff --git a/packages/typescript-twoslash-queries/index.ts b/packages/typescript-twoslash-queries/index.ts index dba28a6a..55488e98 100644 --- a/packages/typescript-twoslash-queries/index.ts +++ b/packages/typescript-twoslash-queries/index.ts @@ -1,7 +1,7 @@ import type { InlayHint, ServicePlugin, ServicePluginInstance } from '@volar/language-service'; import type { Provide } from 'volar-service-typescript'; -export function create(): ServicePlugin { +export function create(ts: typeof import('typescript')): ServicePlugin { return { name: 'typescript-twoslash-queries', create(context): ServicePluginInstance { @@ -9,7 +9,6 @@ export function create(): ServicePlugin { provideInlayHints(document, range) { if (isTsDocument(document.languageId)) { - const ts = context.inject('typescript/typescript'); const languageService = context.inject('typescript/languageService'); const inlayHints: InlayHint[] = []; diff --git a/packages/typescript/directiveComment.ts b/packages/typescript/directiveComment.ts new file mode 100644 index 00000000..9fc37d36 --- /dev/null +++ b/packages/typescript/directiveComment.ts @@ -0,0 +1,85 @@ +import type * as vscode from '@volar/language-service'; +import * as nls from 'vscode-nls'; +import { isTsDocument } from './lib/shared'; + +const localize = nls.loadMessageBundle(); // TODO: not working + +interface Directive { + readonly value: string; + readonly description: string; +} + +const directives: Directive[] = [ + { + value: '@ts-check', + description: localize( + 'ts-check', + "Enables semantic checking in a JavaScript file. Must be at the top of a file.") + }, { + value: '@ts-nocheck', + description: localize( + 'ts-nocheck', + "Disables semantic checking in a JavaScript file. Must be at the top of a file.") + }, { + value: '@ts-ignore', + description: localize( + 'ts-ignore', + "Suppresses @ts-check errors on the next line of a file.") + }, { + value: '@ts-expect-error', + description: localize( + 'ts-expect-error', + "Suppresses @ts-check errors on the next line of a file, expecting at least one to exist.") + } +]; + +export function create(): vscode.ServicePlugin { + return { + name: 'typescript-directive-comment', + triggerCharacters: ['@'], + create(): vscode.ServicePluginInstance { + + return { + + provideCompletionItems(document, position) { + + if (!isTsDocument(document)) + return; + + const prefix = document.getText({ + start: { line: position.line, character: 0 }, + end: position, + }); + const match = prefix.match(/^\s*\/\/+\s?(@[a-zA-Z\-]*)?$/); + if (match) { + + const items = directives.map(directive => { + + const item: vscode.CompletionItem = { label: directive.value }; + item.insertTextFormat = 2 satisfies typeof vscode.InsertTextFormat.Snippet; + item.detail = directive.description; + const range: vscode.Range = { + start: { + line: position.line, + character: Math.max(0, position.character - (match[1] ? match[1].length : 0)), + }, + end: position, + }; + item.textEdit = { + range, + newText: directive.value, + }; + + return item; + }); + + return { + isIncomplete: false, + items, + }; + } + }, + }; + }, + }; +} diff --git a/packages/typescript/lib/features/completions/jsDoc.ts b/packages/typescript/docCommentTemplate.ts similarity index 64% rename from packages/typescript/lib/features/completions/jsDoc.ts rename to packages/typescript/docCommentTemplate.ts index 8e33433e..08430cf1 100644 --- a/packages/typescript/lib/features/completions/jsDoc.ts +++ b/packages/typescript/docCommentTemplate.ts @@ -1,44 +1,56 @@ import type * as vscode from '@volar/language-service'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import * as nls from 'vscode-nls'; -import type { SharedContext } from '../../types'; -import { getLineText } from './resolve'; +import { isTsDocument } from './lib/shared'; +import { getLanguageService } from './lib/syntacticLanguageService'; +import { getLineText } from './lib/utils/lspConverters'; const localize = nls.loadMessageBundle(); // TODO: not working const defaultJsDoc = `/**\n * $0\n */`; -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position) => { +export function create(ts: typeof import('typescript')): vscode.ServicePlugin { + return { + name: 'typescript-doc-comment-template', + triggerCharacters: ['*'], + create(): vscode.ServicePluginInstance { - const document = ctx.getTextDocument(uri); - if (!document) - return; + return { - if (!isPotentiallyValidDocCompletionPosition(document, position)) - return; + provideCompletionItems(document, position) { - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); + if (!isTsDocument(document)) + return; - const docCommentTemplate = ctx.languageService.getDocCommentTemplateAtPosition(fileName, offset); - if (!docCommentTemplate) - return; + if (!isPotentiallyValidDocCompletionPosition(document, position)) + return; - let insertText: string; + const { languageService, fileName } = getLanguageService(ts, document); + const offset = document.offsetAt(position); + const docCommentTemplate = languageService.getDocCommentTemplateAtPosition(fileName, offset); + if (!docCommentTemplate) + return; - // Workaround for #43619 - // docCommentTemplate previously returned undefined for empty jsdoc templates. - // TS 2.7 now returns a single line doc comment, which breaks indentation. - if (docCommentTemplate.newText === '/** */') { - insertText = defaultJsDoc; - } else { - insertText = templateToSnippet(docCommentTemplate.newText); - } + let insertText: string; + + // Workaround for #43619 + // docCommentTemplate previously returned undefined for empty jsdoc templates. + // TS 2.7 now returns a single line doc comment, which breaks indentation. + if (docCommentTemplate.newText === '/** */') { + insertText = defaultJsDoc; + } else { + insertText = templateToSnippet(docCommentTemplate.newText); + } - const item = createCompletionItem(document, position, insertText); + const item = createCompletionItem(document, position, insertText); - return item; + return { + isIncomplete: false, + items: [item], + }; + }, + }; + }, }; } diff --git a/packages/typescript/index.ts b/packages/typescript/index.ts index a3266fec..164c8268 100644 --- a/packages/typescript/index.ts +++ b/packages/typescript/index.ts @@ -1,703 +1,19 @@ -import type { CancellationToken, CompletionList, CompletionTriggerKind, FileChangeType, Result, ServiceContext, ServicePlugin, ServicePluginInstance, VirtualCode } from '@volar/language-service'; -import { getDocumentRegistry } from '@volar/typescript'; -import * as semver from 'semver'; -import type * as ts from 'typescript'; -import * as tsFaster from 'typescript-auto-import-cache'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import * as _callHierarchy from './lib/features/callHierarchy'; -import * as codeActions from './lib/features/codeAction'; -import * as codeActionResolve from './lib/features/codeActionResolve'; -import * as completions from './lib/features/completions/basic'; -import * as directiveCommentCompletions from './lib/features/completions/directiveComment'; -import * as jsDocCompletions from './lib/features/completions/jsDoc'; -import * as completionResolve from './lib/features/completions/resolve'; -import * as definitions from './lib/features/definition'; -import * as diagnostics from './lib/features/diagnostics'; -import * as documentHighlight from './lib/features/documentHighlight'; -import * as documentSymbol from './lib/features/documentSymbol'; -import * as fileReferences from './lib/features/fileReferences'; -import * as fileRename from './lib/features/fileRename'; -import * as foldingRanges from './lib/features/foldingRanges'; -import * as formatting from './lib/features/formatting'; -import * as hover from './lib/features/hover'; -import * as implementation from './lib/features/implementation'; -import * as inlayHints from './lib/features/inlayHints'; -import * as prepareRename from './lib/features/prepareRename'; -import * as references from './lib/features/references'; -import * as rename from './lib/features/rename'; -import * as selectionRanges from './lib/features/selectionRanges'; -import * as semanticTokens from './lib/features/semanticTokens'; -import * as signatureHelp from './lib/features/signatureHelp'; -import * as typeDefinitions from './lib/features/typeDefinition'; -import * as workspaceSymbols from './lib/features/workspaceSymbol'; -import { getConfigTitle, isJsonDocument, isTsDocument } from './lib/shared'; -import type { SharedContext } from './lib/types'; - export * from '@volar/typescript'; +export { Provide } from './semantic'; -export interface Provide { - 'typescript/typescript': () => typeof import('typescript'); - 'typescript/languageService': () => ts.LanguageService; - 'typescript/languageServiceHost': () => ts.LanguageServiceHost; -}; +import { create as createDirectiveCommentServicePlugin } from './directiveComment'; +import { create as createDocCommentTemplateServicePlugin } from './docCommentTemplate'; +import { create as createSemanticServicePlugin } from './semantic'; +import { create as createSyntacticServicePlugin } from './syntactic'; export function create( ts: typeof import('typescript'), - { - isFormattingEnabled = async (document, context) => { - return await context.env.getConfiguration?.(getConfigTitle(document) + '.format.enable') ?? true; - }, - isValidationEnabled = async (document, context) => { - return await context.env.getConfiguration?.(getConfigTitle(document) + '.validate.enable') ?? true; - }, - isSuggestionsEnabled = async (document, context) => { - return await context.env.getConfiguration?.(getConfigTitle(document) + '.suggest.enabled') ?? true; - }, - isAutoClosingTagsEnabled = async (document, context) => { - return await context.env.getConfiguration?.(getConfigTitle(document) + '.autoClosingTags') ?? true; - }, - }: { - isFormattingEnabled?(document: TextDocument, context: ServiceContext): Result; - isValidationEnabled?(document: TextDocument, context: ServiceContext): Result; - isSuggestionsEnabled?(document: TextDocument, context: ServiceContext): Result; - isAutoClosingTagsEnabled?(document: TextDocument, context: ServiceContext): Result; - } = {}, -): ServicePlugin { - const basicTriggerCharacters = getBasicTriggerCharacters(ts.version); - const jsDocTriggerCharacter = '*'; - const directiveCommentTriggerCharacter = '@'; - return { - name: 'typescript', - triggerCharacters: [ - ...basicTriggerCharacters, - jsDocTriggerCharacter, - directiveCommentTriggerCharacter, - ], - signatureHelpTriggerCharacters: ['(', ',', '<'], - signatureHelpRetriggerCharacters: [')'], - // https://github.com/microsoft/vscode/blob/ce119308e8fd4cd3f992d42b297588e7abe33a0c/extensions/typescript-language-features/src/languageFeatures/formatting.ts#L99 - autoFormatTriggerCharacters: [';', '}', '\n'], - create(context): ServicePluginInstance { - const syntactic = createSyntacticServicePlugin(context); - if (!context.language.typescript) { - return syntactic; - } - else { - const semantic = createSemanticServicePlugin(context, context.language.typescript); - return { - ...syntactic, - ...semantic, - dispose() { - syntactic.dispose?.(); - semantic.dispose?.(); - }, - }; - } - }, - }; - - function createSemanticServicePlugin(context: ServiceContext, { sys, languageServiceHost }: NonNullable) { - - const created = tsFaster.createLanguageService( - ts, - sys, - languageServiceHost, - proxiedHost => ts.createLanguageService(proxiedHost, getDocumentRegistry(ts, sys.useCaseSensitiveFileNames, languageServiceHost.getCurrentDirectory())), - ); - const { languageService } = created; - - if (created.setPreferences && context.env.getConfiguration) { - - updatePreferences(); - context.env.onDidChangeConfiguration?.(updatePreferences); - - async function updatePreferences() { - const preferences = await context.env.getConfiguration?.('typescript.preferences'); - if (preferences) { - created.setPreferences?.(preferences); - } - } - } - - if (created.projectUpdated) { - - const sourceScriptNames = new Set(); - const normalizeFileName = sys.useCaseSensitiveFileNames - ? (id: string) => id - : (id: string) => id.toLowerCase(); - - updateSourceScriptFileNames(); - - context.env.onDidChangeWatchedFiles?.((params) => { - const someFileCreateOrDeiete = params.changes.some(change => change.type !== 2 satisfies typeof FileChangeType.Changed); - if (someFileCreateOrDeiete) { - updateSourceScriptFileNames(); - } - for (const change of params.changes) { - const fileName = context.env.typescript!.uriToFileName(change.uri); - if (sourceScriptNames.has(normalizeFileName(fileName))) { - created.projectUpdated?.(languageServiceHost.getCurrentDirectory()); - } - } - }); - - function updateSourceScriptFileNames() { - sourceScriptNames.clear(); - for (const fileName of languageServiceHost.getScriptFileNames()) { - const uri = context.env.typescript!.fileNameToUri(fileName); - const sourceFile = context.language.files.get(uri); - if (sourceFile?.generated) { - const tsCode = sourceFile.generated.languagePlugin.typescript?.getScript(sourceFile.generated.code); - if (tsCode) { - sourceScriptNames.add(normalizeFileName(fileName)); - } - } - else if (sourceFile) { - sourceScriptNames.add(normalizeFileName(fileName)); - } - } - } - } - - const sharedContext: SharedContext = { - ...context, - languageServiceHost, - languageService, - ts, - uriToFileName(uri) { - const virtualScript = getVirtualScriptByUri(uri); - if (virtualScript) { - return virtualScript.fileName; - } - return context.env.typescript!.uriToFileName(uri); - }, - fileNameToUri(fileName) { - - const uri = context.env.typescript!.fileNameToUri(fileName); - const sourceFile = context.language.files.get(uri); - const extraScript = context.language.typescript!.getExtraScript(fileName); - - let virtualCode = extraScript?.code; - - if (!virtualCode && sourceFile?.generated?.languagePlugin.typescript) { - const mainScript = sourceFile.generated.languagePlugin.typescript.getScript(sourceFile.generated.code); - if (mainScript) { - virtualCode = mainScript.code; - } - } - if (virtualCode) { - const sourceFile = context.language.files.getByVirtualCode(virtualCode); - return context.documents.getVirtualCodeUri(sourceFile.id, virtualCode.id); - } - - return uri; - }, - getTextDocument(uri) { - const virtualCode = context.documents.getVirtualCodeByUri(uri)[0]; - if (virtualCode) { - return context.documents.get(uri, virtualCode.languageId, virtualCode.snapshot); - } - const sourceFile = context.language.files.get(uri); - if (sourceFile) { - return context.documents.get(uri, sourceFile.languageId, sourceFile.snapshot); - } - }, - }; - const findDefinition = definitions.register(sharedContext); - const findTypeDefinition = typeDefinitions.register(sharedContext); - const findReferences = references.register(sharedContext); - const findFileReferences = fileReferences.register(sharedContext); - const findImplementations = implementation.register(sharedContext); - const doPrepareRename = prepareRename.register(sharedContext); - const doRename = rename.register(sharedContext); - const getEditsForFileRename = fileRename.register(sharedContext); - const getCodeActions = codeActions.register(sharedContext); - const doCodeActionResolve = codeActionResolve.register(sharedContext); - const getInlayHints = inlayHints.register(sharedContext); - const findDocumentHighlights = documentHighlight.register(sharedContext); - const findWorkspaceSymbols = workspaceSymbols.register(sharedContext); - const doComplete = completions.register(sharedContext); - const doCompletionResolve = completionResolve.register(sharedContext); - const doDirectiveCommentComplete = directiveCommentCompletions.register(sharedContext); - const doJsDocComplete = jsDocCompletions.register(sharedContext); - const doHover = hover.register(sharedContext); - const getSignatureHelp = signatureHelp.register(sharedContext); - const getSelectionRanges = selectionRanges.register(sharedContext); - const doValidation = diagnostics.register(sharedContext); - const getDocumentSemanticTokens = semanticTokens.register(sharedContext); - const callHierarchy = _callHierarchy.register(sharedContext); - const plugin: ServicePluginInstance = { - - provide: { - 'typescript/typescript': () => ts, - 'typescript/languageService': () => languageService, - 'typescript/languageServiceHost': () => languageServiceHost, - }, - - dispose() { - languageService.dispose(); - }, - - async provideCompletionItems(document, position, completeContext, token) { - - if (!isSemanticDocument(document)) - return; - - if (!await isSuggestionsEnabled(document, context)) - return; - - return await worker(token, async () => { - - let result: CompletionList = { - isIncomplete: false, - items: [], - }; - - if (!completeContext || completeContext.triggerKind !== 2 satisfies typeof CompletionTriggerKind.TriggerCharacter || (completeContext.triggerCharacter && basicTriggerCharacters.includes(completeContext.triggerCharacter))) { - - const completeOptions: ts.GetCompletionsAtPositionOptions = { - triggerCharacter: completeContext.triggerCharacter as ts.CompletionsTriggerCharacter, - triggerKind: completeContext.triggerKind, - }; - const basicResult = await doComplete(document.uri, position, completeOptions); - - if (basicResult) { - result = basicResult; - } - } - if (!completeContext || completeContext.triggerKind !== 2 satisfies typeof CompletionTriggerKind.TriggerCharacter || completeContext.triggerCharacter === jsDocTriggerCharacter) { - - const jsdocResult = await doJsDocComplete(document.uri, position); - - if (jsdocResult) { - result.items.push(jsdocResult); - } - } - if (!completeContext || completeContext.triggerKind !== 2 satisfies typeof CompletionTriggerKind.TriggerCharacter || completeContext.triggerCharacter === directiveCommentTriggerCharacter) { - - const directiveCommentResult = await doDirectiveCommentComplete(document.uri, position); - - if (directiveCommentResult) { - result.items = result.items.concat(directiveCommentResult); - } - } - - return result; - }); - }, - - resolveCompletionItem(item, token) { - return worker(token, () => { - return doCompletionResolve(item); - }); - }, - - provideRenameRange(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return doPrepareRename(document.uri, position); - }); - }, - - provideRenameEdits(document, position, newName, token) { - - if (!isSemanticDocument(document, true)) - return; - - return worker(token, () => { - return doRename(document.uri, position, newName); - }); - }, - - provideCodeActions(document, range, context, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return getCodeActions(document.uri, range, context); - }); - }, - - resolveCodeAction(codeAction, token) { - return worker(token, () => { - return doCodeActionResolve(codeAction); - }); - }, - - provideInlayHints(document, range, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return getInlayHints(document.uri, range); - }); - }, - - provideCallHierarchyItems(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return callHierarchy.doPrepare(document.uri, position); - }); - }, - - provideCallHierarchyIncomingCalls(item, token) { - return worker(token, () => { - return callHierarchy.getIncomingCalls(item); - }); - }, - - provideCallHierarchyOutgoingCalls(item, token) { - return worker(token, () => { - return callHierarchy.getOutgoingCalls(item); - }); - }, - - provideDefinition(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return findDefinition(document.uri, position); - }); - }, - - provideTypeDefinition(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return findTypeDefinition(document.uri, position); - }); - }, - - async provideDiagnostics(document, token) { - - if (!isSemanticDocument(document)) - return; - - if (!await isValidationEnabled(document, context)) - return; - - return await worker(token, () => { - return doValidation(document.uri, { syntactic: true, suggestion: true }); - }); - }, - - async provideSemanticDiagnostics(document, token) { - - if (!isSemanticDocument(document)) - return; - - if (!await isValidationEnabled(document, context)) - return; - - return worker(token, () => { - return doValidation(document.uri, { semantic: true, declaration: true }); - }); - }, - - provideHover(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return doHover(document.uri, position); - }); - }, - - provideImplementation(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return findImplementations(document.uri, position); - }); - }, - - provideReferences(document, position, referenceContext, token) { - - if (!isSemanticDocument(document, true)) - return; - - return worker(token, () => { - return findReferences(document.uri, position, referenceContext); - }); - }, - - provideFileReferences(document, token) { - - if (!isSemanticDocument(document, true)) - return; - - return worker(token, () => { - return findFileReferences(document.uri); - }); - }, - - provideDocumentHighlights(document, position, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return findDocumentHighlights(document.uri, position); - }); - }, - - provideDocumentSemanticTokens(document, range, legend, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return getDocumentSemanticTokens(document.uri, range, legend); - }); - }, - - provideWorkspaceSymbols(query, token) { - return worker(token, () => { - return findWorkspaceSymbols(query); - }); - }, - - provideFileRenameEdits(oldUri, newUri, token) { - return worker(token, () => { - return getEditsForFileRename(oldUri, newUri); - }); - }, - - provideSelectionRanges(document, positions, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return getSelectionRanges(document.uri, positions); - }); - }, - - provideSignatureHelp(document, position, context, token) { - - if (!isSemanticDocument(document)) - return; - - return worker(token, () => { - return getSignatureHelp(document.uri, position, context); - }); - }, - }; - - return plugin; - - function isSemanticDocument(document: TextDocument, withJson = false) { - const virtualScript = getVirtualScriptByUri(document.uri); - if (virtualScript) { - return true; - } - if (withJson && isJsonDocument(document)) { - return true; - } - return isTsDocument(document); - } - - async function worker(token: CancellationToken, callback: () => T): Promise> { - - let oldSysVersion = await sys.sync?.(); - let result = await callback(); - let newSysVersion = await sys.sync?.(); - - while (newSysVersion !== oldSysVersion && !token.isCancellationRequested) { - oldSysVersion = newSysVersion; - result = await callback(); - newSysVersion = await sys.sync?.(); - } - - return result; - } - - function getVirtualScriptByUri(uri: string): { - fileName: string; - code: VirtualCode; - } | undefined { - const [virtualCode, sourceFile] = context.documents.getVirtualCodeByUri(uri); - if (virtualCode && sourceFile.generated?.languagePlugin.typescript) { - const { getScript, getExtraScripts } = sourceFile.generated?.languagePlugin.typescript; - const sourceFileName = context.env.typescript!.uriToFileName(sourceFile.id); - if (getScript(sourceFile.generated.code)?.code === virtualCode) { - return { - fileName: sourceFileName, - code: virtualCode, - }; - } - for (const extraScript of getExtraScripts?.(sourceFileName, sourceFile.generated.code) ?? []) { - if (extraScript.code === virtualCode) { - return extraScript; - } - } - } - } - } - - function createSyntacticServicePlugin(context: ServiceContext) { - let currentProjectVersion = -1; - let currentDocument: TextDocument | undefined; - let currentDocumentVersion: number | undefined; - let currentFileName = ''; - let currentFileVersion = 0; - let currentSnapshot = ts.ScriptSnapshot.fromString(''); - - const host: ts.LanguageServiceHost = { - getProjectVersion: () => currentProjectVersion.toString(), - getScriptFileNames: () => [currentFileName], - getScriptVersion: fileName => fileName === currentFileName ? currentFileVersion.toString() : '', - getScriptSnapshot: fileName => fileName === currentFileName ? currentSnapshot : undefined, - getCompilationSettings: () => ({}), - getCurrentDirectory: () => '', - getDefaultLibFileName: () => '', - readFile: () => undefined, - fileExists: fileName => fileName === currentFileName, - }; - const sharedContext: SharedContext = { - ...context, - languageServiceHost: host, - languageService: ts.createLanguageService(host, undefined, 2 satisfies ts.LanguageServiceMode.Syntactic), - ts, - uriToFileName: uri => { - if (uri !== currentDocument?.uri) { - throw new Error(`uriToFileName: uri not found: ${uri}`); - } - return currentFileName; - }, - fileNameToUri: fileName => { - if (fileName !== currentFileName) { - throw new Error(`fileNameToUri: fileName not found: ${fileName}`); - } - return currentDocument!.uri; - }, - getTextDocument(uri) { - if (uri !== currentDocument?.uri) { - throw new Error(`getTextDocument: uri not found: ${uri}`); - } - return currentDocument; - }, - }; - const findDocumentSymbols = documentSymbol.register(sharedContext); - const doFormatting = formatting.register(sharedContext); - const getFoldingRanges = foldingRanges.register(sharedContext); - const plugin: ServicePluginInstance = { - - provide: { - 'typescript/typescript': () => ts, - 'typescript/languageService': () => sharedContext.languageService, - 'typescript/languageServiceHost': () => sharedContext.languageServiceHost, - }, - - async provideAutoInsertionEdit(document, position, lastChange) { - if ( - (document.languageId === 'javascriptreact' || document.languageId === 'typescriptreact') - && lastChange.text.endsWith('>') - && await isAutoClosingTagsEnabled(document, context) - ) { - setupCurrentDocument(document); - const close = sharedContext.languageService.getJsxClosingTagAtPosition(currentFileName, document.offsetAt(position)); - - if (close) { - return '$0' + close.newText; - } - } - }, - - provideFoldingRanges(document) { - - if (!isTsDocument(document)) - return; - - setupCurrentDocument(document); - - return getFoldingRanges(document.uri); - }, - - provideDocumentSymbols(document) { - - if (!isTsDocument(document)) - return; - - setupCurrentDocument(document); - - return findDocumentSymbols(document.uri); - }, - - async provideDocumentFormattingEdits(document, range, options, codeOptions) { - - if (!isTsDocument(document)) - return; - - if (!await isFormattingEnabled(document, context)) - return; - - setupCurrentDocument(document); - - return await doFormatting.onRange(document, range, options, codeOptions); - }, - - async provideOnTypeFormattingEdits(document, position, key, options, codeOptions) { - - if (!isTsDocument(document)) - return; - - if (!await isFormattingEnabled(document, context)) - return; - - setupCurrentDocument(document); - - return doFormatting.onType(document, options, codeOptions, position, key); - }, - }; - return plugin; - - function setupCurrentDocument(document: TextDocument) { - if (currentDocument !== document || currentDocumentVersion !== document.version) { - currentDocument = document; - currentFileName = '/tmp.' + ( - document.languageId === 'javascript' ? 'js' : - document.languageId === 'typescriptreact' ? 'tsx' : - document.languageId === 'javascriptreact' ? 'jsx' : - 'ts' - ); - currentFileVersion++; - currentSnapshot = ts.ScriptSnapshot.fromString(document.getText()); - currentProjectVersion++; - } - } - } -} - -function getBasicTriggerCharacters(tsVersion: string) { - - const triggerCharacters = ['.', '"', '\'', '`', '/', '<']; - - // https://github.com/microsoft/vscode/blob/8e65ae28d5fb8b3c931135da1a41edb9c80ae46f/extensions/typescript-language-features/src/languageFeatures/completions.ts#L811-L833 - if (semver.lt(tsVersion, '3.1.0') || semver.gte(tsVersion, '3.2.0')) { - triggerCharacters.push('@'); - } - if (semver.gte(tsVersion, '3.8.1')) { - triggerCharacters.push('#'); - } - if (semver.gte(tsVersion, '4.3.0')) { - triggerCharacters.push(' '); - } - - return triggerCharacters; + options: Parameters[1] & Parameters[1] +) { + return [ + createSemanticServicePlugin(ts, options), + createSyntacticServicePlugin(ts, options), + createDocCommentTemplateServicePlugin(ts), + createDirectiveCommentServicePlugin(), + ]; } diff --git a/packages/typescript/lib/configs/getFormatCodeSettings.ts b/packages/typescript/lib/configs/getFormatCodeSettings.ts index eb26d9f3..763705e4 100644 --- a/packages/typescript/lib/configs/getFormatCodeSettings.ts +++ b/packages/typescript/lib/configs/getFormatCodeSettings.ts @@ -1,11 +1,10 @@ -import type { FormattingOptions } from '@volar/language-service'; +import type { FormattingOptions, ServiceContext } from '@volar/language-service'; import type * as ts from 'typescript'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import { getConfigTitle } from '../shared'; -import type { SharedContext } from '../types'; export async function getFormatCodeSettings( - ctx: SharedContext, + ctx: ServiceContext, document: TextDocument, options?: FormattingOptions, ): Promise { diff --git a/packages/typescript/lib/configs/getUserPreferences.ts b/packages/typescript/lib/configs/getUserPreferences.ts index 3e84ced2..61d1e668 100644 --- a/packages/typescript/lib/configs/getUserPreferences.ts +++ b/packages/typescript/lib/configs/getUserPreferences.ts @@ -2,7 +2,7 @@ import * as path from 'path-browserify'; import type * as ts from 'typescript'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import { getConfigTitle } from '../shared'; -import type { SharedContext } from '../types'; +import type { SharedContext } from '../semanticFeatures/types'; export async function getUserPreferences( ctx: SharedContext, diff --git a/packages/typescript/lib/features/callHierarchy.ts b/packages/typescript/lib/features/callHierarchy.ts deleted file mode 100644 index 6c393900..00000000 --- a/packages/typescript/lib/features/callHierarchy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import * as path from 'path-browserify'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import * as PConst from '../protocol.const'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { parseKindModifier } from '../utils/modifiers'; -import * as typeConverters from '../utils/typeConverters'; - -export function register(ctx: SharedContext) { - function doPrepare(uri: string, position: vscode.Position) { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const calls = safeCall(() => ctx.languageService.prepareCallHierarchy(fileName, offset)); - if (!calls) return []; - - const items = Array.isArray(calls) ? calls : [calls]; - - return items.map(item => fromProtocolCallHierarchyItem(item)); - } - function getIncomingCalls(item: vscode.CallHierarchyItem) { - - const document = ctx.getTextDocument(item.uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(item.uri); - const offset = document.offsetAt(item.selectionRange.start); - const calls = safeCall(() => ctx.languageService.provideCallHierarchyIncomingCalls(fileName, offset)); - if (!calls) return []; - - const items = Array.isArray(calls) ? calls : [calls]; - - return items.map(item => fromProtocolCallHierarchyIncomingCall(item)); - } - function getOutgoingCalls(item: vscode.CallHierarchyItem) { - - const document = ctx.getTextDocument(item.uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(item.uri); - const offset = document.offsetAt(item.selectionRange.start); - const calls = safeCall(() => ctx.languageService.provideCallHierarchyOutgoingCalls(fileName, offset)); - if (!calls) return []; - - const items = Array.isArray(calls) ? calls : [calls]; - - return items.map(item => fromProtocolCallHierarchyOutgoingCall(item, document)); - } - - return { - doPrepare, - getIncomingCalls, - getOutgoingCalls, - }; - - function isSourceFileItem(item: ts.CallHierarchyItem) { - return item.kind === PConst.Kind.script || item.kind === PConst.Kind.module && item.selectionSpan.start === 0; - } - - function fromProtocolCallHierarchyItem(item: ts.CallHierarchyItem): vscode.CallHierarchyItem { - const rootPath = ctx.languageService.getProgram()?.getCompilerOptions().rootDir ?? ''; - const document = ctx.getTextDocument(ctx.fileNameToUri(item.file))!; // TODO - const useFileName = isSourceFileItem(item); - const name = useFileName ? path.basename(item.file) : item.name; - const detail = useFileName ? path.relative(rootPath, path.dirname(item.file)) : item.containerName ?? ''; - const result: vscode.CallHierarchyItem = { - kind: typeConverters.SymbolKind.fromProtocolScriptElementKind(item.kind), - name, - detail, - uri: ctx.fileNameToUri(item.file), - range: { - start: document.positionAt(item.span.start), - end: document.positionAt(item.span.start + item.span.length), - }, - selectionRange: { - start: document.positionAt(item.selectionSpan.start), - end: document.positionAt(item.selectionSpan.start + item.selectionSpan.length), - }, - }; - - const kindModifiers = item.kindModifiers ? parseKindModifier(item.kindModifiers) : undefined; - if (kindModifiers?.has(PConst.KindModifiers.deprecated)) { - result.tags = [1 satisfies typeof vscode.SymbolTag.Deprecated]; - } - return result; - } - - function fromProtocolCallHierarchyIncomingCall(item: ts.CallHierarchyIncomingCall): vscode.CallHierarchyIncomingCall { - const document = ctx.getTextDocument(ctx.fileNameToUri(item.from.file))!; - return { - from: fromProtocolCallHierarchyItem(item.from), - fromRanges: item.fromSpans.map(fromSpan => ({ - start: document.positionAt(fromSpan.start), - end: document.positionAt(fromSpan.start + fromSpan.length), - })), - }; - } - - function fromProtocolCallHierarchyOutgoingCall(item: ts.CallHierarchyOutgoingCall, document: TextDocument): vscode.CallHierarchyOutgoingCall { - return { - to: fromProtocolCallHierarchyItem(item.to), - fromRanges: item.fromSpans.map(fromSpan => ({ - start: document.positionAt(fromSpan.start), - end: document.positionAt(fromSpan.start + fromSpan.length), - })), - }; - } -}; diff --git a/packages/typescript/lib/features/completions/basic.ts b/packages/typescript/lib/features/completions/basic.ts deleted file mode 100644 index 705a797f..00000000 --- a/packages/typescript/lib/features/completions/basic.ts +++ /dev/null @@ -1,404 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import * as semver from 'semver'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { getUserPreferences } from '../../configs/getUserPreferences'; -import * as PConst from '../../protocol.const'; -import { safeCall } from '../../shared'; -import type { SharedContext } from '../../types'; -import { parseKindModifier } from '../../utils/modifiers'; - -export interface Data { - uri: string, - fileName: string, - offset: number, - originalItem: { - name: ts.CompletionEntry['name'], - source: ts.CompletionEntry['source'], - data: ts.CompletionEntry['data'], - labelDetails: ts.CompletionEntry['labelDetails'], - }; -} - -export function register(ctx: SharedContext) { - - const { ts } = ctx; - const lt_320 = semver.lt(ts.version, '3.2.0'); - const gte_300 = semver.gte(ts.version, '3.0.0'); - - return async (uri: string, position: vscode.Position, options?: ts.GetCompletionsAtPositionOptions): Promise => { - - const document = ctx.getTextDocument(uri); - if (!document) - return; - - const preferences = await getUserPreferences(ctx, document); - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const completionContext = safeCall(() => ctx.languageService.getCompletionsAtPosition(fileName, offset, { - ...preferences, - ...options, - })); - - if (completionContext === undefined) - return; - - const wordRange: vscode.Range | undefined = completionContext.optionalReplacementSpan ? { - start: document.positionAt(completionContext.optionalReplacementSpan.start), - end: document.positionAt(completionContext.optionalReplacementSpan.start + completionContext.optionalReplacementSpan.length), - } : undefined; - - let line = document.getText({ - start: { line: position.line, character: 0 }, - end: { line: position.line + 1, character: 0 }, - }); - if (line.endsWith('\n')) { - line = line.substring(0, line.length - 1); - } - - const dotAccessorContext = getDotAccessorContext(document); - - const entries = completionContext.entries - .map(tsEntry => toVScodeItem(tsEntry, document)); - - return { - isIncomplete: !!completionContext.isIncomplete, - items: entries, - }; - - function toVScodeItem(tsEntry: ts.CompletionEntry, document: TextDocument) { - - const item: vscode.CompletionItem = { label: tsEntry.name }; - - item.kind = convertKind(tsEntry.kind); - - if (tsEntry.source && tsEntry.hasAction) { - // De-prioritize auto-imports - // https://github.com/microsoft/vscode/issues/40311 - item.sortText = '\uffff' + tsEntry.sortText; - - } else { - item.sortText = tsEntry.sortText; - } - - const { sourceDisplay, isSnippet, labelDetails } = tsEntry; - if (sourceDisplay) { - item.labelDetails ??= {}; - item.labelDetails.description = ts.displayPartsToString(sourceDisplay); - } - - if (labelDetails) { - item.labelDetails ??= {}; - Object.assign(item.labelDetails, labelDetails); - } - - item.preselect = tsEntry.isRecommended; - - let range: vscode.Range | ReturnType = getRangeFromReplacementSpan(tsEntry, document); - item.commitCharacters = getCommitCharacters(tsEntry, { - isNewIdentifierLocation: completionContext!.isNewIdentifierLocation, - isInValidCommitCharacterContext: isInValidCommitCharacterContext(document, position), - enableCallCompletions: true, // TODO: suggest.completeFunctionCalls - }); - item.insertText = tsEntry.insertText; - item.insertTextFormat = isSnippet ? 2 satisfies typeof vscode.InsertTextFormat.Snippet : 1 satisfies typeof vscode.InsertTextFormat.PlainText; - item.filterText = getFilterText(tsEntry, wordRange, line, tsEntry.insertText); - - if (completionContext?.isMemberCompletion && dotAccessorContext && !isSnippet) { - item.filterText = dotAccessorContext.text + (item.insertText || item.label); - if (!range) { - const replacementRange = wordRange; - if (replacementRange) { - range = { - inserting: dotAccessorContext.range, - replacing: rangeUnion(dotAccessorContext.range, replacementRange), - }; - } else { - range = dotAccessorContext.range; - } - item.insertText = item.filterText; - } - } - - handleKindModifiers(item, tsEntry); - - if (!range && wordRange) { - range = { - inserting: { start: wordRange.start, end: position }, - replacing: wordRange, - }; - } - - if (range) { - if ('start' in range) { - item.textEdit = { - range, - newText: item.insertText || item.label, - }; - } - else { - item.textEdit = { - insert: range.inserting, - replace: range.replacing, - newText: item.insertText || item.label, - }; - } - } - - return { - ...item, - data: { - uri, - fileName, - offset, - originalItem: { - name: tsEntry.name, - source: tsEntry.source, - data: tsEntry.data, - labelDetails: tsEntry.labelDetails, - }, - } satisfies Data, - }; - } - - function getDotAccessorContext(document: TextDocument) { - let dotAccessorContext: { - range: vscode.Range; - text: string; - } | undefined; - - if (gte_300) { - - if (!completionContext) - return; - - const isMemberCompletion = completionContext.isMemberCompletion; - if (isMemberCompletion) { - const dotMatch = line.slice(0, position.character).match(/\??\.\s*$/) || undefined; - if (dotMatch) { - const range = { - start: { line: position.line, character: position.character - dotMatch[0].length }, - end: position, - }; - const text = document.getText(range); - dotAccessorContext = { range, text }; - } - } - } - - return dotAccessorContext; - } - - // from vscode typescript - function getRangeFromReplacementSpan(tsEntry: ts.CompletionEntry, document: TextDocument) { - if (!tsEntry.replacementSpan) { - return; - } - - let replaceRange: vscode.Range = { - start: document.positionAt(tsEntry.replacementSpan.start), - end: document.positionAt(tsEntry.replacementSpan.start + tsEntry.replacementSpan.length), - }; - // Make sure we only replace a single line at most - if (replaceRange.start.line !== replaceRange.end.line) { - replaceRange = { - start: { - line: replaceRange.start.line, - character: replaceRange.start.character, - }, - end: { - line: replaceRange.start.line, - character: document.positionAt(document.offsetAt({ line: replaceRange.start.line + 1, character: 0 }) - 1).character, - }, - }; - } - - // If TS returns an explicit replacement range, we should use it for both types of completion - return { - inserting: replaceRange, - replacing: replaceRange, - }; - } - - function getFilterText(tsEntry: ts.CompletionEntry, wordRange: vscode.Range | undefined, line: string, insertText: string | undefined): string | undefined { - // Handle private field completions - if (tsEntry.name.startsWith('#')) { - const wordStart = wordRange ? line.charAt(wordRange.start.character) : undefined; - if (insertText) { - if (insertText.startsWith('this.#')) { - return wordStart === '#' ? insertText : insertText.replace(/^this\.#/, ''); - } else { - return insertText; - } - } else { - return wordStart === '#' ? undefined : tsEntry.name.replace(/^#/, ''); - } - } - - // For `this.` completions, generally don't set the filter text since we don't want them to be overly prioritized. #74164 - if (insertText?.startsWith('this.')) { - return undefined; - } - - // Handle the case: - // ``` - // const xyz = { 'ab c': 1 }; - // xyz.ab| - // ``` - // In which case we want to insert a bracket accessor but should use `.abc` as the filter text instead of - // the bracketed insert text. - else if (insertText?.startsWith('[')) { - return insertText.replace(/^\[['"](.+)[['"]\]$/, '.$1'); - } - - // In all other cases, fallback to using the insertText - return insertText; - } - - function convertKind(kind: string): vscode.CompletionItemKind { - switch (kind) { - case PConst.Kind.primitiveType: - case PConst.Kind.keyword: - return 14 satisfies typeof vscode.CompletionItemKind.Keyword; - - case PConst.Kind.const: - case PConst.Kind.let: - case PConst.Kind.variable: - case PConst.Kind.localVariable: - case PConst.Kind.alias: - case PConst.Kind.parameter: - return 6 satisfies typeof vscode.CompletionItemKind.Variable; - - case PConst.Kind.memberVariable: - case PConst.Kind.memberGetAccessor: - case PConst.Kind.memberSetAccessor: - return 5 satisfies typeof vscode.CompletionItemKind.Field; - - case PConst.Kind.function: - case PConst.Kind.localFunction: - return 3 satisfies typeof vscode.CompletionItemKind.Function; - - case PConst.Kind.method: - case PConst.Kind.constructSignature: - case PConst.Kind.callSignature: - case PConst.Kind.indexSignature: - return 2 satisfies typeof vscode.CompletionItemKind.Method; - - case PConst.Kind.enum: - return 13 satisfies typeof vscode.CompletionItemKind.Enum; - - case PConst.Kind.enumMember: - return 20 satisfies typeof vscode.CompletionItemKind.EnumMember; - - case PConst.Kind.module: - case PConst.Kind.externalModuleName: - return 9 satisfies typeof vscode.CompletionItemKind.Module; - - case PConst.Kind.class: - case PConst.Kind.type: - return 7 satisfies typeof vscode.CompletionItemKind.Class; - - case PConst.Kind.interface: - return 8 satisfies typeof vscode.CompletionItemKind.Interface; - - case PConst.Kind.warning: - return 1 satisfies typeof vscode.CompletionItemKind.Text; - - case PConst.Kind.script: - return 17 satisfies typeof vscode.CompletionItemKind.File; - - case PConst.Kind.directory: - return 19 satisfies typeof vscode.CompletionItemKind.Folder; - - case PConst.Kind.string: - return 21 satisfies typeof vscode.CompletionItemKind.Constant; - - default: - return 10 satisfies typeof vscode.CompletionItemKind.Property; - } - } - - function getCommitCharacters(entry: ts.CompletionEntry, context: { - isNewIdentifierLocation: boolean, - isInValidCommitCharacterContext: boolean, - enableCallCompletions: boolean, - }): string[] | undefined { - if (entry.kind === PConst.Kind.warning) { // Ambient JS word based suggestion - return undefined; - } - - if (context.isNewIdentifierLocation || !context.isInValidCommitCharacterContext) { - return undefined; - } - - const commitCharacters: string[] = ['.', ',', ';']; - if (context.enableCallCompletions) { - commitCharacters.push('('); - } - - return commitCharacters; - } - - function isInValidCommitCharacterContext( - document: TextDocument, - position: vscode.Position, - ): boolean { - if (lt_320) { - // Workaround for https://github.com/microsoft/TypeScript/issues/27742 - // Only enable dot completions when the previous character is not a dot preceded by whitespace. - // Prevents incorrectly completing while typing spread operators. - if (position.character > 1) { - const preText = document.getText({ - start: { line: position.line, character: 0 }, - end: position, - }); - return preText.match(/(\s|^)\.$/ig) === null; - } - } - - return true; - } - }; -} - -export function handleKindModifiers(item: vscode.CompletionItem, tsEntry: ts.CompletionEntry | ts.CompletionEntryDetails) { - if (tsEntry.kindModifiers) { - const kindModifiers = parseKindModifier(tsEntry.kindModifiers); - if (kindModifiers.has(PConst.KindModifiers.optional)) { - if (!item.insertText) { - item.insertText = item.label; - } - - if (!item.filterText) { - item.filterText = item.label; - } - item.label += '?'; - } - if (kindModifiers.has(PConst.KindModifiers.deprecated)) { - item.tags = [1 satisfies typeof vscode.CompletionItemTag.Deprecated]; - } - - if (kindModifiers.has(PConst.KindModifiers.color)) { - item.kind = 16 satisfies typeof vscode.CompletionItemKind.Color; - } - - if (tsEntry.kind === PConst.Kind.script) { - for (const extModifier of PConst.KindModifiers.fileExtensionKindModifiers) { - if (kindModifiers.has(extModifier)) { - if (tsEntry.name.toLowerCase().endsWith(extModifier)) { - item.detail = tsEntry.name; - } else { - item.detail = tsEntry.name + extModifier; - } - break; - } - } - } - } -} - -function rangeUnion(a: vscode.Range, b: vscode.Range): vscode.Range { - const start = (a.start.line < b.start.line || (a.start.line === b.start.line && a.start.character < b.start.character)) ? a.start : b.start; - const end = (a.end.line > b.end.line || (a.end.line === b.end.line && a.end.character > b.end.character)) ? a.end : b.end; - return { start, end }; -} diff --git a/packages/typescript/lib/features/completions/directiveComment.ts b/packages/typescript/lib/features/completions/directiveComment.ts deleted file mode 100644 index 1cd878db..00000000 --- a/packages/typescript/lib/features/completions/directiveComment.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import * as nls from 'vscode-nls'; -import type { SharedContext } from '../../types'; - -const localize = nls.loadMessageBundle(); // TODO: not working - -interface Directive { - readonly value: string; - readonly description: string; -} - -const directives: Directive[] = [ - { - value: '@ts-check', - description: localize( - 'ts-check', - "Enables semantic checking in a JavaScript file. Must be at the top of a file.") - }, { - value: '@ts-nocheck', - description: localize( - 'ts-nocheck', - "Disables semantic checking in a JavaScript file. Must be at the top of a file.") - }, { - value: '@ts-ignore', - description: localize( - 'ts-ignore', - "Suppresses @ts-check errors on the next line of a file.") - }, { - value: '@ts-expect-error', - description: localize( - 'ts-expect-error', - "Suppresses @ts-check errors on the next line of a file, expecting at least one to exist.") - } -]; - -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position) => { - - const document = ctx.getTextDocument(uri); - if (!document) - return; - - const prefix = document.getText({ - start: { line: position.line, character: 0 }, - end: position, - }); - const match = prefix.match(/^\s*\/\/+\s?(@[a-zA-Z\-]*)?$/); - if (match) { - - return directives.map(directive => { - - const item: vscode.CompletionItem = { label: directive.value }; - item.insertTextFormat = 2 satisfies typeof vscode.InsertTextFormat.Snippet; - item.detail = directive.description; - const range: vscode.Range = { - start: { - line: position.line, - character: Math.max(0, position.character - (match[1] ? match[1].length : 0)), - }, - end: position, - }; - item.textEdit = { - range, - newText: directive.value, - }; - - return item; - }); - } - return []; - }; -} diff --git a/packages/typescript/lib/features/completions/resolve.ts b/packages/typescript/lib/features/completions/resolve.ts deleted file mode 100644 index 2c7252f3..00000000 --- a/packages/typescript/lib/features/completions/resolve.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { getFormatCodeSettings } from '../../configs/getFormatCodeSettings'; -import { getUserPreferences } from '../../configs/getUserPreferences'; -import { getConfigTitle } from '../../shared'; -import type { SharedContext } from '../../types'; -import * as previewer from '../../utils/previewer'; -import { snippetForFunctionCall } from '../../utils/snippetForFunctionCall'; -import { entriesToLocations } from '../../utils/transforms'; -import type { Data } from './basic'; -import { handleKindModifiers } from './basic'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return async (item: vscode.CompletionItem, newPosition?: vscode.Position): Promise => { - - const data: Data | undefined = item.data; - - if (!data) - return item; - - const fileName = data.fileName; - let offset = data.offset; - const document = ctx.getTextDocument(data.uri); - - if (newPosition && document) { - offset = document.offsetAt(newPosition); - } - - const [formatOptions, preferences] = document ? await Promise.all([ - getFormatCodeSettings(ctx, document), - getUserPreferences(ctx, document), - ]) : [{}, {}]; - - let details: ts.CompletionEntryDetails | undefined; - try { - details = ctx.languageService.getCompletionEntryDetails(fileName, offset, data.originalItem.name, formatOptions, data.originalItem.source, preferences, data.originalItem.data); - } - catch (err) { - item.detail = `[TS Error]\n${err}\n${JSON.stringify(err, undefined, 2)}`; - } - - if (!details) - return item; - - if (data.originalItem.labelDetails) { - item.labelDetails ??= {}; - Object.assign(item.labelDetails, data.originalItem.labelDetails); - } - - const { sourceDisplay } = details; - if (sourceDisplay) { - item.labelDetails ??= {}; - item.labelDetails.description = ts.displayPartsToString(sourceDisplay); - } - - const detailTexts: string[] = []; - if (details.codeActions) { - if (!item.additionalTextEdits) item.additionalTextEdits = []; - for (const action of details.codeActions) { - detailTexts.push(action.description); - for (const changes of action.changes) { - const entries = changes.textChanges.map(textChange => { - return { fileName, textSpan: textChange.span }; - }); - const locs = entriesToLocations(entries, ctx); - locs.forEach((loc, index) => { - item.additionalTextEdits?.push({ range: loc.range, newText: changes.textChanges[index].newText }); - }); - } - } - } - if (details.displayParts) { - detailTexts.push(previewer.plainWithLinks(details.displayParts, { toResource }, ctx)); - } - if (detailTexts.length) { - item.detail = detailTexts.join('\n'); - } - - item.documentation = { - kind: 'markdown', - value: previewer.markdownDocumentation(details.documentation, details.tags, { toResource }, ctx), - }; - - if (details) { - handleKindModifiers(item, details); - } - - if (document) { - - const useCodeSnippetsOnMethodSuggest = await ctx.env.getConfiguration?.(getConfigTitle(document) + '.suggest.completeFunctionCalls') ?? false; - const useCodeSnippet = useCodeSnippetsOnMethodSuggest && (item.kind === 3 satisfies typeof vscode.CompletionItemKind.Function || item.kind === 2 satisfies typeof vscode.CompletionItemKind.Method); - - if (useCodeSnippet) { - const shouldCompleteFunction = isValidFunctionCompletionContext(ctx.languageService, fileName, offset, document); - if (shouldCompleteFunction) { - const { snippet, parameterCount } = snippetForFunctionCall( - { - insertText: item.insertText ?? item.textEdit?.newText, // insertText is dropped by LSP in some case: https://github.com/microsoft/vscode-languageserver-node/blob/9b742021fb04ad081aa3676a9eecf4fa612084b4/client/src/common/codeConverter.ts#L659-L664 - label: item.label, - }, - details.displayParts, - ); - if (item.textEdit) { - item.textEdit.newText = snippet; - } - if (item.insertText) { - item.insertText = snippet; - } - item.insertTextFormat = 2 satisfies typeof vscode.InsertTextFormat.Snippet; - if (parameterCount > 0) { - //Fix for https://github.com/microsoft/vscode/issues/104059 - //Don't show parameter hints if "editor.parameterHints.enabled": false - // if (await getConfiguration('editor.parameterHints.enabled', document.uri)) { - // item.command = { - // title: 'triggerParameterHints', - // command: 'editor.action.triggerParameterHints', - // }; - // } - } - } - } - } - - return item; - - function toResource(path: string) { - return ctx.fileNameToUri(path); - } - }; -} - -function isValidFunctionCompletionContext( - client: ts.LanguageService, - filepath: string, - offset: number, - document: TextDocument, -): boolean { - // Workaround for https://github.com/microsoft/TypeScript/issues/12677 - // Don't complete function calls inside of destructive assignments or imports - try { - const response = client.getQuickInfoAtPosition(filepath, offset); - if (response) { - switch (response.kind) { - case 'var': - case 'let': - case 'const': - case 'alias': - return false; - } - } - } catch { - // Noop - } - - // Don't complete function call if there is already something that looks like a function call - // https://github.com/microsoft/vscode/issues/18131 - const position = document.positionAt(offset); - const after = getLineText(document, position.line).slice(position.character); - return after.match(/^[a-z_$0-9]*\s*\(/gi) === null; -} - -export function getLineText(document: TextDocument, line: number) { - const endOffset = document.offsetAt({ line: line + 1, character: 0 }); - const end = document.positionAt(endOffset); - const text = document.getText({ - start: { line: line, character: 0 }, - end: end.line === line ? end : document.positionAt(endOffset - 1), - }); - return text; -} diff --git a/packages/typescript/lib/features/definition.ts b/packages/typescript/lib/features/definition.ts deleted file mode 100644 index 1eede380..00000000 --- a/packages/typescript/lib/features/definition.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { boundSpanToLocationLinks } from '../utils/transforms'; - -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position) => { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const info = safeCall(() => ctx.languageService.getDefinitionAndBoundSpan(fileName, offset)); - if (!info) return []; - - return boundSpanToLocationLinks(info, document, ctx); - }; -} diff --git a/packages/typescript/lib/features/diagnostics.ts b/packages/typescript/lib/features/diagnostics.ts deleted file mode 100644 index ceb4afb2..00000000 --- a/packages/typescript/lib/features/diagnostics.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return ( - uri: string, - options: { - semantic?: boolean, - syntactic?: boolean, - suggestion?: boolean, - declaration?: boolean, - }, - ): vscode.Diagnostic[] => { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const program = ctx.languageService.getProgram(); - const sourceFile = program?.getSourceFile(fileName); - if (!program || !sourceFile) return []; - - const token: ts.CancellationToken = { - isCancellationRequested() { - return ctx.language.typescript?.languageServiceHost.getCancellationToken?.().isCancellationRequested() ?? false; - }, - throwIfCancellationRequested() { }, - }; - - let errors = safeCall(() => [ - ...options.semantic ? program.getSemanticDiagnostics(sourceFile, token) : [], - ...options.syntactic ? program.getSyntacticDiagnostics(sourceFile, token) : [], - ...options.suggestion ? ctx.languageService.getSuggestionDiagnostics(fileName) : [], - ]) ?? []; - - if (options.declaration && getEmitDeclarations(program.getCompilerOptions())) { - errors = errors.concat(program.getDeclarationDiagnostics(sourceFile, token)); - } - - return translateDiagnostics(document, errors); - - function translateDiagnostics(document: TextDocument, input: readonly ts.Diagnostic[]) { - return input.map(diag => translateDiagnostic(diag, document)).filter((v): v is NonNullable => !!v); - } - function translateDiagnostic(diag: ts.Diagnostic, document: TextDocument): vscode.Diagnostic | undefined { - - if (diag.start === undefined) return; - if (diag.length === undefined) return; - - const diagnostic: vscode.Diagnostic = { - range: { - start: document.positionAt(diag.start), - end: document.positionAt(diag.start + diag.length), - }, - severity: translateErrorType(diag.category), - source: 'ts', - code: diag.code, - message: getMessageText(diag), - }; - - if (diag.relatedInformation) { - diagnostic.relatedInformation = diag.relatedInformation - .map(rErr => translateDiagnosticRelated(rErr)) - .filter((v): v is NonNullable => !!v); - } - if (diag.reportsUnnecessary) { - if (diagnostic.tags === undefined) diagnostic.tags = []; - diagnostic.tags.push(1 satisfies typeof vscode.DiagnosticTag.Unnecessary); - } - if (diag.reportsDeprecated) { - if (diagnostic.tags === undefined) diagnostic.tags = []; - diagnostic.tags.push(2 satisfies typeof vscode.DiagnosticTag.Deprecated); - } - - return diagnostic; - } - function translateDiagnosticRelated(diag: ts.Diagnostic): vscode.DiagnosticRelatedInformation | undefined { - - if (diag.start === undefined) return; - if (diag.length === undefined) return; - - let document: TextDocument | undefined; - if (diag.file) { - document = ctx.getTextDocument(ctx.fileNameToUri(diag.file.fileName)); - } - if (!document) return; - - const diagnostic: vscode.DiagnosticRelatedInformation = { - location: { - uri: document.uri, - range: { - start: document.positionAt(diag.start), - end: document.positionAt(diag.start + diag.length), - }, - }, - message: getMessageText(diag), - }; - - return diagnostic; - } - function translateErrorType(input: ts.DiagnosticCategory): vscode.DiagnosticSeverity { - switch (input) { - case ts.DiagnosticCategory.Warning: return 2 satisfies typeof vscode.DiagnosticSeverity.Warning; - case ts.DiagnosticCategory.Error: return 1 satisfies typeof vscode.DiagnosticSeverity.Error; - case ts.DiagnosticCategory.Suggestion: return 4 satisfies typeof vscode.DiagnosticSeverity.Hint; - case ts.DiagnosticCategory.Message: return 3 satisfies typeof vscode.DiagnosticSeverity.Information; - } - return 1 satisfies typeof vscode.DiagnosticSeverity.Error; - } - }; -} - -function getMessageText(diag: ts.Diagnostic | ts.DiagnosticMessageChain, level = 0) { - let messageText = ' '.repeat(level); - - if (typeof diag.messageText === 'string') { - messageText += diag.messageText; - } - else { - messageText += diag.messageText.messageText; - if (diag.messageText.next) { - for (const info of diag.messageText.next) { - messageText += '\n' + getMessageText(info, level + 1); - } - } - } - - return messageText; -} -export function getEmitDeclarations(compilerOptions: ts.CompilerOptions): boolean { - return !!(compilerOptions.declaration || compilerOptions.composite); -} diff --git a/packages/typescript/lib/features/documentHighlight.ts b/packages/typescript/lib/features/documentHighlight.ts deleted file mode 100644 index e993e5d5..00000000 --- a/packages/typescript/lib/features/documentHighlight.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return (uri: string, position: vscode.Position): vscode.DocumentHighlight[] => { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const highlights = safeCall(() => ctx.languageService.getDocumentHighlights(fileName, offset, [fileName])); - if (!highlights) return []; - - const results: vscode.DocumentHighlight[] = []; - - for (const highlight of highlights) { - for (const span of highlight.highlightSpans) { - results.push({ - kind: span.kind === ts.HighlightSpanKind.writtenReference ? 3 satisfies typeof vscode.DocumentHighlightKind.Write : 2 satisfies typeof vscode.DocumentHighlightKind.Read, - range: { - start: document.positionAt(span.textSpan.start), - end: document.positionAt(span.textSpan.start + span.textSpan.length), - }, - }); - } - } - - return results; - }; -} diff --git a/packages/typescript/lib/features/documentSymbol.ts b/packages/typescript/lib/features/documentSymbol.ts deleted file mode 100644 index 0a3f5818..00000000 --- a/packages/typescript/lib/features/documentSymbol.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import * as PConst from '../protocol.const'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { parseKindModifier } from '../utils/modifiers'; - -const getSymbolKind = (kind: string): vscode.SymbolKind => { - switch (kind) { - case PConst.Kind.module: return 2 satisfies typeof vscode.SymbolKind.Module; - case PConst.Kind.class: return 5 satisfies typeof vscode.SymbolKind.Class; - case PConst.Kind.enum: return 10 satisfies typeof vscode.SymbolKind.Enum; - case PConst.Kind.interface: return 11 satisfies typeof vscode.SymbolKind.Interface; - case PConst.Kind.method: return 6 satisfies typeof vscode.SymbolKind.Method; - case PConst.Kind.memberVariable: return 7 satisfies typeof vscode.SymbolKind.Property; - case PConst.Kind.memberGetAccessor: return 7 satisfies typeof vscode.SymbolKind.Property; - case PConst.Kind.memberSetAccessor: return 7 satisfies typeof vscode.SymbolKind.Property; - case PConst.Kind.variable: return 13 satisfies typeof vscode.SymbolKind.Variable; - case PConst.Kind.const: return 13 satisfies typeof vscode.SymbolKind.Variable; - case PConst.Kind.localVariable: return 13 satisfies typeof vscode.SymbolKind.Variable; - case PConst.Kind.function: return 12 satisfies typeof vscode.SymbolKind.Function; - case PConst.Kind.localFunction: return 12 satisfies typeof vscode.SymbolKind.Function; - case PConst.Kind.constructSignature: return 9 satisfies typeof vscode.SymbolKind.Constructor; - case PConst.Kind.constructorImplementation: return 9 satisfies typeof vscode.SymbolKind.Constructor; - } - return 13 satisfies typeof vscode.SymbolKind.Variable; -}; - -export function register(ctx: SharedContext) { - return (uri: string): vscode.DocumentSymbol[] => { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const barItems = safeCall(() => ctx.languageService.getNavigationTree(fileName)); - if (!barItems) return []; - - // The root represents the file. Ignore this when showing in the UI - const result = barItems.childItems - ?.map( - function convertNavTree(item): vscode.DocumentSymbol[] { - if (!shouldIncludeEntry(item)) { - return []; - } - let remain = item.childItems ?? []; - return item.spans.map(span => { - const childItems: ts.NavigationTree[] = []; - remain = remain.filter(child => { - const childStart = child.spans[0].start; - const childEnd = child.spans[child.spans.length - 1].start + child.spans[child.spans.length - 1].length; - if (childStart >= span.start && childEnd <= span.start + span.length) { - childItems.push(child); - return false; - } - return true; - }); - const nameSpan = item.spans.length === 1 - ? (item.nameSpan ?? span) - : span; - const fullRange = { - start: Math.min(span.start, nameSpan.start), - end: Math.max(span.start + span.length, nameSpan.start + nameSpan.length), - }; - const symbol: vscode.DocumentSymbol = { - name: item.text, - kind: getSymbolKind(item.kind), - range: { - start: document.positionAt(fullRange.start), - end: document.positionAt(fullRange.end), - }, - selectionRange: { - start: document.positionAt(nameSpan.start), - end: document.positionAt(nameSpan.start + nameSpan.length), - }, - children: childItems.map(convertNavTree).flat(), - }; - const kindModifiers = parseKindModifier(item.kindModifiers); - if (kindModifiers.has(PConst.KindModifiers.deprecated)) { - symbol.deprecated = true; - symbol.tags ??= []; - symbol.tags.push(1 satisfies typeof vscode.SymbolTag.Deprecated); - } - return symbol; - }); - } - ) - .flat(); - - return result ?? []; - - function shouldIncludeEntry(item: ts.NavigationTree): boolean { - if (item.kind === PConst.Kind.alias) { - return false; - } - return !!(item.text && item.text !== '' && item.text !== ''); - } - }; -} diff --git a/packages/typescript/lib/features/fileReferences.ts b/packages/typescript/lib/features/fileReferences.ts deleted file mode 100644 index 0056721e..00000000 --- a/packages/typescript/lib/features/fileReferences.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { entriesToLocations } from '../utils/transforms'; - -export function register(ctx: SharedContext) { - return (uri: string): vscode.Location[] => { - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const entries = safeCall(() => ctx.languageService.getFileReferences(fileName)); - if (!entries) return []; - - return entriesToLocations([...entries], ctx); - }; -} diff --git a/packages/typescript/lib/features/fileRename.ts b/packages/typescript/lib/features/fileRename.ts deleted file mode 100644 index 91b85d7b..00000000 --- a/packages/typescript/lib/features/fileRename.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type * as vscode from 'vscode-languageserver-protocol'; -import { getFormatCodeSettings } from '../configs/getFormatCodeSettings'; -import { getUserPreferences } from '../configs/getUserPreferences'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { fileTextChangesToWorkspaceEdit } from './rename'; - -export function register(ctx: SharedContext) { - return async (oldUri: string, newUri: string): Promise => { - - const document = ctx.getTextDocument(oldUri); - const [formatOptions, preferences] = document ? await Promise.all([ - getFormatCodeSettings(ctx, document), - getUserPreferences(ctx, document), - ]) : [{}, {}]; - - const fileToRename = ctx.uriToFileName(oldUri); - const newFilePath = ctx.uriToFileName(newUri); - const response = safeCall(() => ctx.languageService.getEditsForFileRename(fileToRename, newFilePath, formatOptions, preferences)); - if (!response?.length) return; - - const edits = fileTextChangesToWorkspaceEdit(response, ctx); - return edits; - }; -} diff --git a/packages/typescript/lib/features/foldingRanges.ts b/packages/typescript/lib/features/foldingRanges.ts deleted file mode 100644 index 1aeec8d6..00000000 --- a/packages/typescript/lib/features/foldingRanges.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return (uri: string) => { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const outliningSpans = safeCall(() => ctx.languageService.getOutliningSpans(fileName)); - if (!outliningSpans) return []; - - const foldingRanges: vscode.FoldingRange[] = []; - - for (const outliningSpan of outliningSpans) { - - const start = document.positionAt(outliningSpan.textSpan.start); - const end = adjustFoldingEnd(start, document.positionAt(outliningSpan.textSpan.start + outliningSpan.textSpan.length), document); - - const foldingRange: vscode.FoldingRange = { - startLine: start.line, - endLine: end.line, - startCharacter: start.character, - endCharacter: end.character, - kind: transformFoldingRangeKind(outliningSpan.kind), - }; - foldingRanges.push(foldingRange); - } - - return foldingRanges; - }; - - function transformFoldingRangeKind(tsKind: ts.OutliningSpanKind) { - switch (tsKind) { - case ts.OutliningSpanKind.Comment: return 'comment' satisfies typeof vscode.FoldingRangeKind.Comment; - case ts.OutliningSpanKind.Imports: return 'imports' satisfies typeof vscode.FoldingRangeKind.Imports; - case ts.OutliningSpanKind.Region: return 'region' satisfies typeof vscode.FoldingRangeKind.Region; - } - } -} - -const foldEndPairCharacters = ['}', ']', ')', '`']; - -// https://github.com/microsoft/vscode/blob/bed61166fb604e519e82e4d1d1ed839bc45d65f8/extensions/typescript-language-features/src/languageFeatures/folding.ts#L61-L73 -function adjustFoldingEnd(start: vscode.Position, end: vscode.Position, document: TextDocument) { - // workaround for #47240 - if (end.character > 0) { - const foldEndCharacter = document.getText({ - start: { line: end.line, character: end.character - 1 }, - end, - }); - if (foldEndPairCharacters.includes(foldEndCharacter)) { - const endOffset = Math.max(document.offsetAt({ line: end.line, character: 0 }) - 1, document.offsetAt(start)); - return document.positionAt(endOffset); - } - } - - return end; -} diff --git a/packages/typescript/lib/features/formatting.ts b/packages/typescript/lib/features/formatting.ts deleted file mode 100644 index eb4f0037..00000000 --- a/packages/typescript/lib/features/formatting.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { getFormatCodeSettings } from '../configs/getFormatCodeSettings'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - return { - onRange: async (document: TextDocument, range: vscode.Range | undefined, options: vscode.FormattingOptions, codeOptions: vscode.EmbeddedCodeFormattingOptions | undefined): Promise => { - - const fileName = ctx.uriToFileName(document.uri); - const tsOptions = await getFormatCodeSettings(ctx, document, options); - - if (codeOptions) { - tsOptions.baseIndentSize = codeOptions.initialIndentLevel * options.tabSize; - } - - const scriptEdits = range - ? safeCall(() => ctx.languageService.getFormattingEditsForRange( - fileName, - document.offsetAt(range.start), - document.offsetAt(range.end), - tsOptions, - )) - : safeCall(() => ctx.languageService.getFormattingEditsForDocument(fileName, tsOptions)); - if (!scriptEdits) return []; - - const result: vscode.TextEdit[] = []; - - for (const textEdit of scriptEdits) { - result.push({ - range: { - start: document.positionAt(textEdit.span.start), - end: document.positionAt(textEdit.span.start + textEdit.span.length), - }, - newText: textEdit.newText, - }); - } - - return result; - }, - onType: async (document: TextDocument, options: vscode.FormattingOptions, codeOptions: vscode.EmbeddedCodeFormattingOptions | undefined, position: vscode.Position, key: string): Promise => { - - const fileName = ctx.uriToFileName(document.uri); - const tsOptions = await getFormatCodeSettings(ctx, document, options); - - if (codeOptions) { - tsOptions.baseIndentSize = codeOptions.initialIndentLevel * options.tabSize; - } - - const scriptEdits = safeCall(() => ctx.languageService.getFormattingEditsAfterKeystroke(fileName, document.offsetAt(position), key, tsOptions)); - if (!scriptEdits) return []; - - const result: vscode.TextEdit[] = []; - - for (const textEdit of scriptEdits) { - result.push({ - range: { - start: document.positionAt(textEdit.span.start), - end: document.positionAt(textEdit.span.start + textEdit.span.length), - }, - newText: textEdit.newText, - }); - } - - return result; - }, - }; -} diff --git a/packages/typescript/lib/features/hover.ts b/packages/typescript/lib/features/hover.ts deleted file mode 100644 index 345f4e08..00000000 --- a/packages/typescript/lib/features/hover.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import * as previewer from '../utils/previewer'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return (uri: string, position: vscode.Position, documentOnly = false): vscode.Hover | undefined => { - const document = ctx.getTextDocument(uri); - if (!document) return; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const info = safeCall(() => ctx.languageService.getQuickInfoAtPosition(fileName, offset)); - if (!info) return; - - const parts: string[] = []; - const displayString = ts.displayPartsToString(info.displayParts); - const documentation = previewer.markdownDocumentation(info.documentation ?? [], info.tags, { toResource }, ctx); - - if (displayString && !documentOnly) { - parts.push(['```typescript', displayString, '```'].join('\n')); - } - if (documentation) { - parts.push(documentation); - } - - const markdown: vscode.MarkupContent = { - kind: 'markdown' satisfies typeof vscode.MarkupKind.Markdown, - value: parts.join('\n\n'), - }; - - return { - contents: markdown, - range: { - start: document.positionAt(info.textSpan.start), - end: document.positionAt(info.textSpan.start + info.textSpan.length), - }, - }; - - function toResource(path: string) { - return ctx.fileNameToUri(path); - } - }; -} diff --git a/packages/typescript/lib/features/implementation.ts b/packages/typescript/lib/features/implementation.ts deleted file mode 100644 index 262db018..00000000 --- a/packages/typescript/lib/features/implementation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type * as vscode from 'vscode-languageserver-protocol'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { entriesToLocationLinks } from '../utils/transforms'; - -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position) => { - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const entries = safeCall(() => ctx.languageService.getImplementationAtPosition(fileName, offset)); - if (!entries) return []; - - return entriesToLocationLinks([...entries], ctx); - }; -} diff --git a/packages/typescript/lib/features/inlayHints.ts b/packages/typescript/lib/features/inlayHints.ts deleted file mode 100644 index 03e6a07b..00000000 --- a/packages/typescript/lib/features/inlayHints.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { getUserPreferences } from '../configs/getUserPreferences'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return async (uri: string, range: vscode.Range) => { - - const document = ctx.getTextDocument(uri); - if (!document) return; - - const preferences = await getUserPreferences(ctx, document); - const fileName = ctx.uriToFileName(document.uri); - const start = document.offsetAt(range.start); - const end = document.offsetAt(range.end); - const inlayHints = safeCall(() => - 'provideInlayHints' in ctx.languageService - ? ctx.languageService.provideInlayHints(fileName, { start, length: end - start }, preferences) - : [] - ) ?? []; - - return inlayHints.map(inlayHint => { - const result: vscode.InlayHint = { - position: document.positionAt(inlayHint.position), - label: inlayHint.text, - kind: inlayHint.kind === ts.InlayHintKind.Type ? 1 satisfies typeof vscode.InlayHintKind.Type - : inlayHint.kind === ts.InlayHintKind.Parameter ? 2 satisfies typeof vscode.InlayHintKind.Parameter - : undefined, - }; - result.paddingLeft = inlayHint.whitespaceBefore; - result.paddingRight = inlayHint.whitespaceAfter; - return result; - }); - }; -} diff --git a/packages/typescript/lib/features/prepareRename.ts b/packages/typescript/lib/features/prepareRename.ts deleted file mode 100644 index 85c2aa89..00000000 --- a/packages/typescript/lib/features/prepareRename.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -/* typescript-language-features is hardcode true */ -export const renameInfoOptions = { allowRenameOfImportPath: true }; - -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position): vscode.Range | { message: string; } | undefined => { - const document = ctx.getTextDocument(uri); - if (!document) return; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const renameInfo = safeCall(() => ctx.languageService.getRenameInfo(fileName, offset, renameInfoOptions)); - if (!renameInfo) return; - - if (!renameInfo.canRename) { - return { message: renameInfo.localizedErrorMessage }; - } - - return { - start: document.positionAt(renameInfo.triggerSpan.start), - end: document.positionAt(renameInfo.triggerSpan.start + renameInfo.triggerSpan.length), - }; - }; -} diff --git a/packages/typescript/lib/features/references.ts b/packages/typescript/lib/features/references.ts deleted file mode 100644 index 0a945038..00000000 --- a/packages/typescript/lib/features/references.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { entryToLocation } from '../utils/transforms'; - -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position, referenceContext: vscode.ReferenceContext): vscode.Location[] => { - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const references = safeCall(() => ctx.languageService.findReferences(fileName, offset)); - if (!references) return []; - - const result: vscode.Location[] = []; - for (const reference of references) { - if (referenceContext.includeDeclaration) { - const definition = entryToLocation(reference.definition, ctx); - if (definition) { - result.push(definition); - } - } - for (const referenceEntry of reference.references) { - const reference = entryToLocation(referenceEntry, ctx); - if (reference) { - result.push(reference); - } - } - } - return result; - }; -} diff --git a/packages/typescript/lib/features/rename.ts b/packages/typescript/lib/features/rename.ts deleted file mode 100644 index 5b30ccd4..00000000 --- a/packages/typescript/lib/features/rename.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import * as path from 'path-browserify'; -import type * as ts from 'typescript'; -import { getFormatCodeSettings } from '../configs/getFormatCodeSettings'; -import { getUserPreferences } from '../configs/getUserPreferences'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { renameInfoOptions } from './prepareRename'; - -export function register(ctx: SharedContext) { - - return async (uri: string, position: vscode.Position, newName: string): Promise => { - const document = ctx.getTextDocument(uri); - if (!document) return; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const renameInfo = safeCall(() => ctx.languageService.getRenameInfo(fileName, offset, renameInfoOptions)); - if (!renameInfo?.canRename) return; - - if (renameInfo.fileToRename) { - const [formatOptions, preferences] = await Promise.all([ - getFormatCodeSettings(ctx, document), - getUserPreferences(ctx, document), - ]); - return renameFile(renameInfo.fileToRename, newName, formatOptions, preferences); - } - - const { providePrefixAndSuffixTextForRename } = await getUserPreferences(ctx, document); - const entries = ctx.languageService.findRenameLocations(fileName, offset, false, false, providePrefixAndSuffixTextForRename); - if (!entries) - return; - - const locations = locationsToWorkspaceEdit(newName, entries, ctx); - return locations; - }; - - function renameFile( - fileToRename: string, - newName: string, - formatOptions: ts.FormatCodeSettings, - preferences: ts.UserPreferences, - ): vscode.WorkspaceEdit | undefined { - // Make sure we preserve file extension if none provided - if (!path.extname(newName)) { - newName += path.extname(fileToRename); - } - - const dirname = path.dirname(fileToRename); - const newFilePath = path.join(dirname, newName); - - const response = ctx.languageService.getEditsForFileRename(fileToRename, newFilePath, formatOptions, preferences); - const edits = fileTextChangesToWorkspaceEdit(response, ctx); - if (!edits.documentChanges) { - edits.documentChanges = []; - } - - edits.documentChanges.push({ - kind: 'rename', - oldUri: ctx.fileNameToUri(fileToRename), - newUri: ctx.fileNameToUri(newFilePath), - }); - - return edits; - } -} - -export function fileTextChangesToWorkspaceEdit( - changes: readonly ts.FileTextChanges[], - ctx: SharedContext, -) { - const workspaceEdit: vscode.WorkspaceEdit = {}; - - for (const change of changes) { - - if (!workspaceEdit.documentChanges) { - workspaceEdit.documentChanges = []; - } - - const uri = ctx.fileNameToUri(change.fileName); - let doc = ctx.getTextDocument(uri); - - if (change.isNewFile) { - workspaceEdit.documentChanges.push({ kind: 'create', uri }); - } - - if (!doc && !change.isNewFile) - continue; - - const docEdit: vscode.TextDocumentEdit = { - textDocument: { - uri, - version: null, // fix https://github.com/johnsoncodehk/volar/issues/2025 - }, - edits: [], - }; - - for (const textChange of change.textChanges) { - docEdit.edits.push({ - newText: textChange.newText, - range: { - start: doc?.positionAt(textChange.span.start) ?? { line: 0, character: 0 }, - end: doc?.positionAt(textChange.span.start + textChange.span.length) ?? { line: 0, character: 0 }, - }, - }); - } - workspaceEdit.documentChanges.push(docEdit); - } - - return workspaceEdit; -} -function locationsToWorkspaceEdit( - newText: string, - locations: readonly ts.RenameLocation[], - ctx: SharedContext, -) { - const workspaceEdit: vscode.WorkspaceEdit = {}; - - for (const location of locations) { - - if (!workspaceEdit.changes) { - workspaceEdit.changes = {}; - } - - const uri = ctx.fileNameToUri(location.fileName); - const doc = ctx.getTextDocument(uri); - if (!doc) continue; - - if (!workspaceEdit.changes[uri]) { - workspaceEdit.changes[uri] = []; - } - - let _newText = newText; - if (location.prefixText) - _newText = location.prefixText + _newText; - if (location.suffixText) - _newText = _newText + location.suffixText; - - workspaceEdit.changes[uri].push({ - newText: _newText, - range: { - start: doc.positionAt(location.textSpan.start), - end: doc.positionAt(location.textSpan.start + location.textSpan.length), - }, - }); - } - - return workspaceEdit; -} diff --git a/packages/typescript/lib/features/selectionRanges.ts b/packages/typescript/lib/features/selectionRanges.ts deleted file mode 100644 index 74d6b5be..00000000 --- a/packages/typescript/lib/features/selectionRanges.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - return (uri: string, positions: vscode.Position[]): vscode.SelectionRange[] => { - - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const result: vscode.SelectionRange[] = []; - - for (const position of positions) { - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const range = safeCall(() => ctx.languageService.getSmartSelectionRange(fileName, offset)); - if (!range) continue; - - result.push(transformSelectionRange(range, document)); - } - - return result; - }; -} - -function transformSelectionRange(range: ts.SelectionRange, document: TextDocument): vscode.SelectionRange { - return { - range: { - start: document.positionAt(range.textSpan.start), - end: document.positionAt(range.textSpan.start + range.textSpan.length), - }, - parent: range.parent ? transformSelectionRange(range.parent, document) : undefined, - }; -} diff --git a/packages/typescript/lib/features/signatureHelp.ts b/packages/typescript/lib/features/signatureHelp.ts deleted file mode 100644 index 10ec7fc5..00000000 --- a/packages/typescript/lib/features/signatureHelp.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; - -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return (uri: string, position: vscode.Position, context?: vscode.SignatureHelpContext): vscode.SignatureHelp | undefined => { - const document = ctx.getTextDocument(uri); - if (!document) return; - - const options: ts.SignatureHelpItemsOptions = {}; - if (context?.triggerKind === 1 satisfies typeof vscode.SignatureHelpTriggerKind.Invoked) { - options.triggerReason = { - kind: 'invoked' - }; - } - else if (context?.triggerKind === 2 satisfies typeof vscode.SignatureHelpTriggerKind.TriggerCharacter) { - options.triggerReason = { - kind: 'characterTyped', - triggerCharacter: context.triggerCharacter as ts.SignatureHelpTriggerCharacter, - }; - } - else if (context?.triggerKind === 3 satisfies typeof vscode.SignatureHelpTriggerKind.ContentChange) { - options.triggerReason = { - kind: 'retrigger', - triggerCharacter: context.triggerCharacter as ts.SignatureHelpRetriggerCharacter, - }; - } - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const helpItems = safeCall(() => ctx.languageService.getSignatureHelpItems(fileName, offset, options)); - if (!helpItems) return; - - return { - activeSignature: helpItems.selectedItemIndex, - activeParameter: helpItems.argumentIndex, - signatures: helpItems.items.map(item => { - const signature: vscode.SignatureInformation = { - label: '', - documentation: undefined, - parameters: [] - }; - signature.label += ts.displayPartsToString(item.prefixDisplayParts); - item.parameters.forEach((p, i, a) => { - const label = ts.displayPartsToString(p.displayParts); - const parameter: vscode.ParameterInformation = { - label, - documentation: ts.displayPartsToString(p.documentation) - }; - signature.label += label; - signature.parameters!.push(parameter); - if (i < a.length - 1) { - signature.label += ts.displayPartsToString(item.separatorDisplayParts); - } - }); - signature.label += ts.displayPartsToString(item.suffixDisplayParts); - return signature; - }), - }; - }; -} diff --git a/packages/typescript/lib/features/typeDefinition.ts b/packages/typescript/lib/features/typeDefinition.ts deleted file mode 100644 index 22c067cb..00000000 --- a/packages/typescript/lib/features/typeDefinition.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type * as vscode from 'vscode-languageserver-protocol'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { entriesToLocationLinks } from '../utils/transforms'; - -export function register(ctx: SharedContext) { - return (uri: string, position: vscode.Position) => { - const document = ctx.getTextDocument(uri); - if (!document) return []; - - const fileName = ctx.uriToFileName(document.uri); - const offset = document.offsetAt(position); - const entries = safeCall(() => ctx.languageService.getTypeDefinitionAtPosition(fileName, offset)); - if (!entries) return []; - - return entriesToLocationLinks([...entries], ctx); - }; -} diff --git a/packages/typescript/lib/features/workspaceSymbol.ts b/packages/typescript/lib/features/workspaceSymbol.ts deleted file mode 100644 index 2bf8d1d5..00000000 --- a/packages/typescript/lib/features/workspaceSymbol.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import * as PConst from '../protocol.const'; -import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; -import { parseKindModifier } from '../utils/modifiers'; - -function getSymbolKind(item: ts.NavigateToItem): vscode.SymbolKind { - switch (item.kind) { - case PConst.Kind.method: return 6 satisfies typeof vscode.SymbolKind.Method; - case PConst.Kind.enum: return 10 satisfies typeof vscode.SymbolKind.Enum; - case PConst.Kind.enumMember: return 22 satisfies typeof vscode.SymbolKind.EnumMember; - case PConst.Kind.function: return 12 satisfies typeof vscode.SymbolKind.Function; - case PConst.Kind.class: return 5 satisfies typeof vscode.SymbolKind.Class; - case PConst.Kind.interface: return 11 satisfies typeof vscode.SymbolKind.Interface; - case PConst.Kind.type: return 5 satisfies typeof vscode.SymbolKind.Class; - case PConst.Kind.memberVariable: return 8 satisfies typeof vscode.SymbolKind.Field; - case PConst.Kind.memberGetAccessor: return 8 satisfies typeof vscode.SymbolKind.Field; - case PConst.Kind.memberSetAccessor: return 8 satisfies typeof vscode.SymbolKind.Field; - case PConst.Kind.variable: return 13 satisfies typeof vscode.SymbolKind.Variable; - default: return 13 satisfies typeof vscode.SymbolKind.Variable; - } -} - -export function register(ctx: SharedContext) { - return (query: string): vscode.WorkspaceSymbol[] => { - - const items = safeCall(() => ctx.languageService.getNavigateToItems(query)); - if (!items) return []; - - return items - .filter(item => item.containerName || item.kind !== 'alias') - .map(toWorkspaceSymbol) - .filter((v): v is NonNullable => !!v); - - function toWorkspaceSymbol(item: ts.NavigateToItem) { - const label = getLabel(item); - const uri = ctx.fileNameToUri(item.fileName); - const document = ctx.getTextDocument(uri); - if (document) { - const range: vscode.Range = { - start: document.positionAt(item.textSpan.start), - end: document.positionAt(item.textSpan.start + item.textSpan.length), - }; - const info: vscode.WorkspaceSymbol = { - name: label, - kind: getSymbolKind(item), - location: { uri, range }, - }; - const kindModifiers = item.kindModifiers ? parseKindModifier(item.kindModifiers) : undefined; - if (kindModifiers?.has(PConst.KindModifiers.deprecated)) { - info.tags = [1 satisfies typeof vscode.SymbolTag.Deprecated]; - } - return info; - } - } - - function getLabel(item: ts.NavigateToItem) { - const label = item.name; - if (item.kind === 'method' || item.kind === 'function') { - return label + '()'; - } - return label; - } - }; -} diff --git a/packages/typescript/lib/features/codeAction.ts b/packages/typescript/lib/semanticFeatures/codeAction.ts similarity index 88% rename from packages/typescript/lib/features/codeAction.ts rename to packages/typescript/lib/semanticFeatures/codeAction.ts index 41e4736d..059bc37c 100644 --- a/packages/typescript/lib/features/codeAction.ts +++ b/packages/typescript/lib/semanticFeatures/codeAction.ts @@ -3,10 +3,11 @@ import type * as ts from 'typescript'; import { getFormatCodeSettings } from '../configs/getFormatCodeSettings'; import { getUserPreferences } from '../configs/getUserPreferences'; import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; +import type { SharedContext } from './types'; import * as fixNames from '../utils/fixNames'; import { resolveFixAllCodeAction, resolveOrganizeImportsCodeAction, resolveRefactorCodeAction } from './codeActionResolve'; -import { fileTextChangesToWorkspaceEdit } from './rename'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import { convertFileTextChanges } from '../utils/lspConverters'; export interface FixAllData { type: 'fixAll', @@ -42,11 +43,7 @@ export function register(ctx: SharedContext) { resolveEditSupport = true; } - return async (uri: string, range: vscode.Range, context: vscode.CodeActionContext) => { - - const document = ctx.getTextDocument(uri); - if (!document) return; - + return async (document: TextDocument, range: vscode.Range, context: vscode.CodeActionContext) => { const [formatOptions, preferences] = await Promise.all([ getFormatCodeSettings(ctx, document), getUserPreferences(ctx, document), @@ -69,7 +66,7 @@ export function register(ctx: SharedContext) { preferences, )) ?? []; for (const codeFix of codeFixes) { - result = result.concat(transformCodeFix(codeFix, [error], onlyQuickFix ?? '' satisfies typeof vscode.CodeActionKind.Empty)); + result = result.concat(convertCodeFixAction(codeFix, [error], onlyQuickFix ?? '' satisfies typeof vscode.CodeActionKind.Empty)); } } } @@ -85,7 +82,7 @@ export function register(ctx: SharedContext) { only, )) ?? []; for (const refactor of refactors) { - result = result.concat(transformRefactor(refactor)); + result = result.concat(convertApplicableRefactorInfo(refactor)); } } } @@ -99,7 +96,7 @@ export function register(ctx: SharedContext) { undefined, )) ?? []; for (const refactor of refactors) { - result = result.concat(transformRefactor(refactor)); + result = result.concat(convertApplicableRefactorInfo(refactor)); } } @@ -111,7 +108,7 @@ export function register(ctx: SharedContext) { }; const data: OrganizeImportsData = { type: 'organizeImports', - uri, + uri: document.uri, fileName, }; if (resolveEditSupport) { @@ -130,7 +127,7 @@ export function register(ctx: SharedContext) { kind: onlySourceFixAll, }; const data: FixAllData = { - uri, + uri: document.uri, type: 'fixAll', fileName, fixIds: [ @@ -155,7 +152,7 @@ export function register(ctx: SharedContext) { kind: onlyRemoveUnused, }; const data: FixAllData = { - uri, + uri: document.uri, type: 'fixAll', fileName, fixIds: [ @@ -184,7 +181,7 @@ export function register(ctx: SharedContext) { kind: onlyAddMissingImports, }; const data: FixAllData = { - uri, + uri: document.uri, type: 'fixAll', fileName, fixIds: [ @@ -234,8 +231,8 @@ export function register(ctx: SharedContext) { } } } - function transformCodeFix(codeFix: ts.CodeFixAction, diagnostics: vscode.Diagnostic[], kind: vscode.CodeActionKind) { - const edit = fileTextChangesToWorkspaceEdit(codeFix.changes, ctx); + function convertCodeFixAction(codeFix: ts.CodeFixAction, diagnostics: vscode.Diagnostic[], kind: vscode.CodeActionKind) { + const edit = convertFileTextChanges(codeFix.changes, ctx.fileNameToUri, ctx.getTextDocument); const codeActions: vscode.CodeAction[] = []; const fix: vscode.CodeAction = { title: codeFix.description, @@ -250,7 +247,7 @@ export function register(ctx: SharedContext) { kind, }; const data: FixAllData = { - uri, + uri: document.uri, type: 'fixAll', fileName, fixIds: [codeFix.fixId], @@ -266,7 +263,7 @@ export function register(ctx: SharedContext) { } return codeActions; } - function transformRefactor(refactor: ts.ApplicableRefactorInfo) { + function convertApplicableRefactorInfo(refactor: ts.ApplicableRefactorInfo) { const codeActions: vscode.CodeAction[] = []; for (const action of refactor.actions) { const codeAction: vscode.CodeAction = { @@ -280,7 +277,7 @@ export function register(ctx: SharedContext) { codeAction.isPreferred = true; } const data: RefactorData = { - uri, + uri: document.uri, type: 'refactor', fileName, range: { pos: start, end: end }, diff --git a/packages/typescript/lib/features/codeActionResolve.ts b/packages/typescript/lib/semanticFeatures/codeActionResolve.ts similarity index 82% rename from packages/typescript/lib/features/codeActionResolve.ts rename to packages/typescript/lib/semanticFeatures/codeActionResolve.ts index 9786bbf5..5102ee0b 100644 --- a/packages/typescript/lib/features/codeActionResolve.ts +++ b/packages/typescript/lib/semanticFeatures/codeActionResolve.ts @@ -4,24 +4,24 @@ import type { TextDocument } from 'vscode-languageserver-textdocument'; import { getFormatCodeSettings } from '../configs/getFormatCodeSettings'; import { getUserPreferences } from '../configs/getUserPreferences'; import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; +import type { SharedContext } from './types'; import type { Data, FixAllData, RefactorData } from './codeAction'; -import { fileTextChangesToWorkspaceEdit } from './rename'; +import { convertFileTextChanges } from '../utils/lspConverters'; export function register(ctx: SharedContext) { return async (codeAction: vscode.CodeAction) => { const data: Data = codeAction.data; const document = ctx.getTextDocument(data.uri); - const [formatOptions, preferences] = document ? await Promise.all([ + const [formatOptions, preferences] = await Promise.all([ getFormatCodeSettings(ctx, document), getUserPreferences(ctx, document), - ]) : [{}, {}]; + ]); if (data?.type === 'fixAll') { resolveFixAllCodeAction(ctx, codeAction, data, formatOptions, preferences); } - else if (data?.type === 'refactor' && document) { + else if (data?.type === 'refactor') { resolveRefactorCodeAction(ctx, codeAction, data, document, formatOptions, preferences); } else if (data?.type === 'organizeImports') { @@ -41,7 +41,7 @@ export function resolveFixAllCodeAction( ) { const fixes = data.fixIds.map(fixId => safeCall(() => ctx.languageService.getCombinedCodeFix({ type: 'file', fileName: data.fileName }, fixId, formatOptions, preferences))); const changes = fixes.map(fix => fix?.changes ?? []).flat(); - codeAction.edit = fileTextChangesToWorkspaceEdit(changes, ctx); + codeAction.edit = convertFileTextChanges(changes, ctx.fileNameToUri, ctx.getTextDocument); } export function resolveRefactorCodeAction( @@ -56,7 +56,7 @@ export function resolveRefactorCodeAction( if (!editInfo) { return; } - codeAction.edit = fileTextChangesToWorkspaceEdit(editInfo.edits, ctx); + codeAction.edit = convertFileTextChanges(editInfo.edits, ctx.fileNameToUri, ctx.getTextDocument); if (editInfo.renameLocation !== undefined && editInfo.renameFilename !== undefined) { codeAction.command = ctx.commands.rename.create( document.uri, @@ -73,5 +73,5 @@ export function resolveOrganizeImportsCodeAction( preferences: ts.UserPreferences, ) { const changes = safeCall(() => ctx.languageService.organizeImports({ type: 'file', fileName: data.fileName }, formatOptions, preferences)); - codeAction.edit = fileTextChangesToWorkspaceEdit(changes ?? [], ctx); + codeAction.edit = convertFileTextChanges(changes ?? [], ctx.fileNameToUri, ctx.getTextDocument); } diff --git a/packages/typescript/lib/features/semanticTokens.ts b/packages/typescript/lib/semanticFeatures/semanticTokens.ts similarity index 94% rename from packages/typescript/lib/features/semanticTokens.ts rename to packages/typescript/lib/semanticFeatures/semanticTokens.ts index 3162846c..e0673b82 100644 --- a/packages/typescript/lib/features/semanticTokens.ts +++ b/packages/typescript/lib/semanticFeatures/semanticTokens.ts @@ -1,22 +1,16 @@ import type * as vscode from '@volar/language-service'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import { safeCall } from '../shared'; -import type { SharedContext } from '../types'; +import type { SharedContext } from './types'; -export function register(ctx: SharedContext) { - const { ts } = ctx; - - return (uri: string, range: vscode.Range, legend: vscode.SemanticTokensLegend) => { - - const document = ctx.getTextDocument(uri); - if (!document) return; - - const file = ctx.uriToFileName(uri); +export function register(ts: typeof import('typescript'), ctx: SharedContext) { + return (document: TextDocument, range: vscode.Range, legend: vscode.SemanticTokensLegend) => { + const fileName = ctx.uriToFileName(document.uri); const start = range ? document.offsetAt(range.start) : 0; const length = range ? (document.offsetAt(range.end) - start) : document.getText().length; if (ctx.language.typescript?.languageServiceHost.getCancellationToken?.().isCancellationRequested()) return; - const response = safeCall(() => ctx.languageService.getEncodedSemanticClassifications(file, { start, length }, ts.SemanticClassificationFormat.TwentyTwenty)); + const response = safeCall(() => ctx.languageService.getEncodedSemanticClassifications(fileName, { start, length }, ts.SemanticClassificationFormat.TwentyTwenty)); if (!response) return; let tokenModifiersTable: number[] = []; diff --git a/packages/typescript/lib/types.ts b/packages/typescript/lib/semanticFeatures/types.ts similarity index 80% rename from packages/typescript/lib/types.ts rename to packages/typescript/lib/semanticFeatures/types.ts index 7077beca..aa4ba6d5 100644 --- a/packages/typescript/lib/types.ts +++ b/packages/typescript/lib/semanticFeatures/types.ts @@ -3,10 +3,9 @@ import type * as ts from 'typescript'; import type { TextDocument } from 'vscode-languageserver-textdocument'; export type SharedContext = ServiceContext & { - ts: typeof import('typescript'); languageServiceHost: ts.LanguageServiceHost; languageService: ts.LanguageService; - getTextDocument: (uri: string) => TextDocument | undefined; + getTextDocument: (uri: string) => TextDocument; uriToFileName: (uri: string) => string; fileNameToUri: (fileName: string) => string; }; diff --git a/packages/typescript/lib/shared.ts b/packages/typescript/lib/shared.ts index b5357e94..79c45ced 100644 --- a/packages/typescript/lib/shared.ts +++ b/packages/typescript/lib/shared.ts @@ -27,3 +27,7 @@ export function safeCall(cb: () => T) { return cb(); } catch { } } + +export function notEmpty(value: any): value is NonNullable { + return value !== null && value !== undefined; +} diff --git a/packages/typescript/lib/syntacticLanguageService.ts b/packages/typescript/lib/syntacticLanguageService.ts new file mode 100644 index 00000000..e6b3625d --- /dev/null +++ b/packages/typescript/lib/syntacticLanguageService.ts @@ -0,0 +1,40 @@ +import type * as ts from 'typescript'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; + +let currentProjectVersion = -1; +let currentDocument: TextDocument | undefined; +let currentDocumentVersion: number | undefined; +let currentFileName = ''; +let currentSnapshot: ts.IScriptSnapshot | undefined; +let languageService: ts.LanguageService | undefined; + +const host: ts.LanguageServiceHost = { + getProjectVersion: () => currentProjectVersion.toString(), + getScriptFileNames: () => [currentFileName], + getScriptVersion: () => currentProjectVersion.toString(), + getScriptSnapshot: fileName => fileName === currentFileName ? currentSnapshot : undefined, + getCompilationSettings: () => ({}), + getCurrentDirectory: () => '', + getDefaultLibFileName: () => '', + readFile: () => undefined, + fileExists: fileName => fileName === currentFileName, +}; + +export function getLanguageService(ts: typeof import('typescript'), document: TextDocument) { + if (currentDocument !== document || currentDocumentVersion !== document.version) { + currentDocument = document; + currentFileName = '/tmp.' + ( + document.languageId === 'javascript' ? 'js' : + document.languageId === 'typescriptreact' ? 'tsx' : + document.languageId === 'javascriptreact' ? 'jsx' : + 'ts' + ); + currentSnapshot = ts.ScriptSnapshot.fromString(document.getText()); + currentProjectVersion++; + } + languageService ??= ts.createLanguageService(host, undefined, 2 satisfies ts.LanguageServiceMode.Syntactic); + return { + languageService, + fileName: currentFileName, + }; +} diff --git a/packages/typescript/lib/utils/lspConverters.ts b/packages/typescript/lib/utils/lspConverters.ts new file mode 100644 index 00000000..ef560b6a --- /dev/null +++ b/packages/typescript/lib/utils/lspConverters.ts @@ -0,0 +1,915 @@ +import type * as vscode from '@volar/language-service'; +import * as path from 'path-browserify'; +import type * as ts from 'typescript'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import * as PConst from '../protocol.const'; +import { notEmpty } from '../shared'; +import type { SharedContext } from '../semanticFeatures/types'; +import { parseKindModifier } from '../utils/modifiers'; +import * as previewer from '../utils/previewer'; +import * as typeConverters from '../utils/typeConverters'; +import * as semver from 'semver'; + +// diagnostics + +export function convertDiagnostic( + diag: ts.Diagnostic, + document: TextDocument, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, +): vscode.Diagnostic | undefined { + + if (diag.start === undefined) return; + if (diag.length === undefined) return; + + const diagnostic: vscode.Diagnostic = { + range: { + start: document.positionAt(diag.start), + end: document.positionAt(diag.start + diag.length), + }, + severity: convertDiagnosticCategory(diag.category), + source: 'ts', + code: diag.code, + message: getMessageText(diag), + }; + + if (diag.relatedInformation) { + diagnostic.relatedInformation = diag.relatedInformation + .map(rErr => convertDiagnosticRelatedInformation(rErr, fileNameToUri, getTextDocument)) + .filter((v): v is NonNullable => !!v); + } + if (diag.reportsUnnecessary) { + if (diagnostic.tags === undefined) diagnostic.tags = []; + diagnostic.tags.push(1 satisfies typeof vscode.DiagnosticTag.Unnecessary); + } + if (diag.reportsDeprecated) { + if (diagnostic.tags === undefined) diagnostic.tags = []; + diagnostic.tags.push(2 satisfies typeof vscode.DiagnosticTag.Deprecated); + } + + return diagnostic; +} + +function convertDiagnosticRelatedInformation( + diag: ts.Diagnostic, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, +): vscode.DiagnosticRelatedInformation | undefined { + + if (diag.start === undefined) return; + if (diag.length === undefined) return; + + let document: TextDocument | undefined; + if (diag.file) { + document = getTextDocument(fileNameToUri(diag.file.fileName)); + } + if (!document) return; + + const diagnostic: vscode.DiagnosticRelatedInformation = { + location: { + uri: document.uri, + range: { + start: document.positionAt(diag.start), + end: document.positionAt(diag.start + diag.length), + }, + }, + message: getMessageText(diag), + }; + + return diagnostic; +} + +function convertDiagnosticCategory(input: ts.DiagnosticCategory): vscode.DiagnosticSeverity { + switch (input) { + case 0 satisfies ts.DiagnosticCategory.Warning: return 2 satisfies typeof vscode.DiagnosticSeverity.Warning; + case 1 satisfies ts.DiagnosticCategory.Error: return 1 satisfies typeof vscode.DiagnosticSeverity.Error; + case 2 satisfies ts.DiagnosticCategory.Suggestion: return 4 satisfies typeof vscode.DiagnosticSeverity.Hint; + case 3 satisfies ts.DiagnosticCategory.Message: return 3 satisfies typeof vscode.DiagnosticSeverity.Information; + } + return 1 satisfies typeof vscode.DiagnosticSeverity.Error; +} + +function getMessageText(diag: ts.Diagnostic | ts.DiagnosticMessageChain, level = 0) { + let messageText = ' '.repeat(level); + + if (typeof diag.messageText === 'string') { + messageText += diag.messageText; + } + else { + messageText += diag.messageText.messageText; + if (diag.messageText.next) { + for (const info of diag.messageText.next) { + messageText += '\n' + getMessageText(info, level + 1); + } + } + } + + return messageText; +} + +// completion resolve + +export function applyCompletionEntryDetails( + ts: typeof import('typescript'), + item: vscode.CompletionItem, + data: ts.CompletionEntryDetails, + document: TextDocument, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, +) { + const { sourceDisplay } = data; + if (sourceDisplay) { + item.labelDetails ??= {}; + item.labelDetails.description = ts.displayPartsToString(sourceDisplay); + } + const detailTexts: string[] = []; + if (data.codeActions) { + item.additionalTextEdits ??= []; + for (const action of data.codeActions) { + detailTexts.push(action.description); + for (const changes of action.changes) { + const ranges = changes.textChanges.map(change => convertTextSpan(change.span, document)); + ranges.forEach((range, index) => { + item.additionalTextEdits?.push({ range, newText: changes.textChanges[index].newText }); + }); + } + } + } + if (data.displayParts) { + detailTexts.push(previewer.plainWithLinks(data.displayParts, fileNameToUri, getTextDocument)); + } + if (detailTexts.length) { + item.detail = detailTexts.join('\n'); + } + item.documentation = { + kind: 'markdown', + value: previewer.markdownDocumentation(data.documentation, data.tags, fileNameToUri, getTextDocument), + }; + if (data) { + handleKindModifiers(item, data); + } +} + +// completion + +export function convertCompletionInfo( + ts: typeof import('typescript'), + completionContext: ts.CompletionInfo, + document: TextDocument, + position: vscode.Position, + createData: (tsEntry: ts.CompletionEntry) => T, +): vscode.CompletionList { + const lt_320 = semver.lt(ts.version, '3.2.0'); + const gte_300 = semver.gte(ts.version, '3.0.0'); + const wordRange: vscode.Range | undefined = completionContext.optionalReplacementSpan + ? convertTextSpan(completionContext.optionalReplacementSpan, document) + : undefined; + const line = getLineText(document, position.line); + const dotAccessorContext = getDotAccessorContext(document); + const entries = completionContext.entries + .map(tsEntry => ({ + ...convertCompletionEntry(tsEntry, document), + data: createData(tsEntry), + })); + return { + isIncomplete: !!completionContext.isIncomplete, + items: entries, + }; + + function convertCompletionEntry(tsEntry: ts.CompletionEntry, document: TextDocument) { + + const item: vscode.CompletionItem = { label: tsEntry.name }; + + item.kind = convertCompletionItemKind(tsEntry.kind); + + if (tsEntry.source && tsEntry.hasAction) { + // De-prioritize auto-imports + // https://github.com/microsoft/vscode/issues/40311 + item.sortText = '\uffff' + tsEntry.sortText; + + } else { + item.sortText = tsEntry.sortText; + } + + const { sourceDisplay, isSnippet, labelDetails } = tsEntry; + if (sourceDisplay) { + item.labelDetails ??= {}; + item.labelDetails.description = ts.displayPartsToString(sourceDisplay); + } + + if (labelDetails) { + item.labelDetails ??= {}; + Object.assign(item.labelDetails, labelDetails); + } + + item.preselect = tsEntry.isRecommended; + + let range: vscode.Range | ReturnType = getRangeFromReplacementSpan(tsEntry, document); + item.commitCharacters = getCommitCharacters(tsEntry, { + isNewIdentifierLocation: completionContext!.isNewIdentifierLocation, + isInValidCommitCharacterContext: isInValidCommitCharacterContext(document, position), + enableCallCompletions: true, // TODO: suggest.completeFunctionCalls + }); + item.insertText = tsEntry.insertText; + item.insertTextFormat = isSnippet ? 2 satisfies typeof vscode.InsertTextFormat.Snippet : 1 satisfies typeof vscode.InsertTextFormat.PlainText; + item.filterText = getFilterText(tsEntry, wordRange, line, tsEntry.insertText); + + if (completionContext?.isMemberCompletion && dotAccessorContext && !isSnippet) { + item.filterText = dotAccessorContext.text + (item.insertText || item.label); + if (!range) { + const replacementRange = wordRange; + if (replacementRange) { + range = { + inserting: dotAccessorContext.range, + replacing: rangeUnion(dotAccessorContext.range, replacementRange), + }; + } else { + range = dotAccessorContext.range; + } + item.insertText = item.filterText; + } + } + + handleKindModifiers(item, tsEntry); + + if (!range && wordRange) { + range = { + inserting: { start: wordRange.start, end: position }, + replacing: wordRange, + }; + } + + if (range) { + if ('start' in range) { + item.textEdit = { + range, + newText: item.insertText || item.label, + }; + } + else { + item.textEdit = { + insert: range.inserting, + replace: range.replacing, + newText: item.insertText || item.label, + }; + } + } + + return item; + } + + function getDotAccessorContext(document: TextDocument) { + let dotAccessorContext: { + range: vscode.Range; + text: string; + } | undefined; + + if (gte_300) { + + if (!completionContext) + return; + + const isMemberCompletion = completionContext.isMemberCompletion; + if (isMemberCompletion) { + const dotMatch = line.slice(0, position.character).match(/\??\.\s*$/) || undefined; + if (dotMatch) { + const range = { + start: { line: position.line, character: position.character - dotMatch[0].length }, + end: position, + }; + const text = document.getText(range); + dotAccessorContext = { range, text }; + } + } + } + + return dotAccessorContext; + } + + // from vscode typescript + function getRangeFromReplacementSpan(tsEntry: ts.CompletionEntry, document: TextDocument) { + if (!tsEntry.replacementSpan) { + return; + } + + let replaceRange: vscode.Range = { + start: document.positionAt(tsEntry.replacementSpan.start), + end: document.positionAt(tsEntry.replacementSpan.start + tsEntry.replacementSpan.length), + }; + // Make sure we only replace a single line at most + if (replaceRange.start.line !== replaceRange.end.line) { + replaceRange = { + start: { + line: replaceRange.start.line, + character: replaceRange.start.character, + }, + end: { + line: replaceRange.start.line, + character: document.positionAt(document.offsetAt({ line: replaceRange.start.line + 1, character: 0 }) - 1).character, + }, + }; + } + + // If TS returns an explicit replacement range, we should use it for both types of completion + return { + inserting: replaceRange, + replacing: replaceRange, + }; + } + + function getFilterText(tsEntry: ts.CompletionEntry, wordRange: vscode.Range | undefined, line: string, insertText: string | undefined): string | undefined { + // Handle private field completions + if (tsEntry.name.startsWith('#')) { + const wordStart = wordRange ? line.charAt(wordRange.start.character) : undefined; + if (insertText) { + if (insertText.startsWith('this.#')) { + return wordStart === '#' ? insertText : insertText.replace(/^this\.#/, ''); + } else { + return insertText; + } + } else { + return wordStart === '#' ? undefined : tsEntry.name.replace(/^#/, ''); + } + } + + // For `this.` completions, generally don't set the filter text since we don't want them to be overly prioritized. #74164 + if (insertText?.startsWith('this.')) { + return undefined; + } + + // Handle the case: + // ``` + // const xyz = { 'ab c': 1 }; + // xyz.ab| + // ``` + // In which case we want to insert a bracket accessor but should use `.abc` as the filter text instead of + // the bracketed insert text. + else if (insertText?.startsWith('[')) { + return insertText.replace(/^\[['"](.+)[['"]\]$/, '.$1'); + } + + // In all other cases, fallback to using the insertText + return insertText; + } + + function getCommitCharacters(entry: ts.CompletionEntry, context: { + isNewIdentifierLocation: boolean, + isInValidCommitCharacterContext: boolean, + enableCallCompletions: boolean, + }): string[] | undefined { + if (entry.kind === PConst.Kind.warning) { // Ambient JS word based suggestion + return undefined; + } + + if (context.isNewIdentifierLocation || !context.isInValidCommitCharacterContext) { + return undefined; + } + + const commitCharacters: string[] = ['.', ',', ';']; + if (context.enableCallCompletions) { + commitCharacters.push('('); + } + + return commitCharacters; + } + + function isInValidCommitCharacterContext( + document: TextDocument, + position: vscode.Position, + ): boolean { + if (lt_320) { + // Workaround for https://github.com/microsoft/TypeScript/issues/27742 + // Only enable dot completions when the previous character is not a dot preceded by whitespace. + // Prevents incorrectly completing while typing spread operators. + if (position.character > 1) { + const preText = document.getText({ + start: { line: position.line, character: 0 }, + end: position, + }); + return preText.match(/(\s|^)\.$/ig) === null; + } + } + + return true; + } +} + +function convertCompletionItemKind(kind: string): vscode.CompletionItemKind { + switch (kind) { + case PConst.Kind.primitiveType: + case PConst.Kind.keyword: + return 14 satisfies typeof vscode.CompletionItemKind.Keyword; + + case PConst.Kind.const: + case PConst.Kind.let: + case PConst.Kind.variable: + case PConst.Kind.localVariable: + case PConst.Kind.alias: + case PConst.Kind.parameter: + return 6 satisfies typeof vscode.CompletionItemKind.Variable; + + case PConst.Kind.memberVariable: + case PConst.Kind.memberGetAccessor: + case PConst.Kind.memberSetAccessor: + return 5 satisfies typeof vscode.CompletionItemKind.Field; + + case PConst.Kind.function: + case PConst.Kind.localFunction: + return 3 satisfies typeof vscode.CompletionItemKind.Function; + + case PConst.Kind.method: + case PConst.Kind.constructSignature: + case PConst.Kind.callSignature: + case PConst.Kind.indexSignature: + return 2 satisfies typeof vscode.CompletionItemKind.Method; + + case PConst.Kind.enum: + return 13 satisfies typeof vscode.CompletionItemKind.Enum; + + case PConst.Kind.enumMember: + return 20 satisfies typeof vscode.CompletionItemKind.EnumMember; + + case PConst.Kind.module: + case PConst.Kind.externalModuleName: + return 9 satisfies typeof vscode.CompletionItemKind.Module; + + case PConst.Kind.class: + case PConst.Kind.type: + return 7 satisfies typeof vscode.CompletionItemKind.Class; + + case PConst.Kind.interface: + return 8 satisfies typeof vscode.CompletionItemKind.Interface; + + case PConst.Kind.warning: + return 1 satisfies typeof vscode.CompletionItemKind.Text; + + case PConst.Kind.script: + return 17 satisfies typeof vscode.CompletionItemKind.File; + + case PConst.Kind.directory: + return 19 satisfies typeof vscode.CompletionItemKind.Folder; + + case PConst.Kind.string: + return 21 satisfies typeof vscode.CompletionItemKind.Constant; + + default: + return 10 satisfies typeof vscode.CompletionItemKind.Property; + } +} + +function handleKindModifiers(item: vscode.CompletionItem, tsEntry: ts.CompletionEntry | ts.CompletionEntryDetails) { + if (tsEntry.kindModifiers) { + const kindModifiers = parseKindModifier(tsEntry.kindModifiers); + if (kindModifiers.has(PConst.KindModifiers.optional)) { + if (!item.insertText) { + item.insertText = item.label; + } + + if (!item.filterText) { + item.filterText = item.label; + } + item.label += '?'; + } + if (kindModifiers.has(PConst.KindModifiers.deprecated)) { + item.tags = [1 satisfies typeof vscode.CompletionItemTag.Deprecated]; + } + + if (kindModifiers.has(PConst.KindModifiers.color)) { + item.kind = 16 satisfies typeof vscode.CompletionItemKind.Color; + } + + if (tsEntry.kind === PConst.Kind.script) { + for (const extModifier of PConst.KindModifiers.fileExtensionKindModifiers) { + if (kindModifiers.has(extModifier)) { + if (tsEntry.name.toLowerCase().endsWith(extModifier)) { + item.detail = tsEntry.name; + } else { + item.detail = tsEntry.name + extModifier; + } + break; + } + } + } + } +} + +function rangeUnion(a: vscode.Range, b: vscode.Range): vscode.Range { + const start = (a.start.line < b.start.line || (a.start.line === b.start.line && a.start.character < b.start.character)) ? a.start : b.start; + const end = (a.end.line > b.end.line || (a.end.line === b.end.line && a.end.character > b.end.character)) ? a.end : b.end; + return { start, end }; +} + +export function getLineText(document: TextDocument, line: number) { + const endOffset = document.offsetAt({ line: line + 1, character: 0 }); + const end = document.positionAt(endOffset); + const text = document.getText({ + start: { line: line, character: 0 }, + end: end.line === line ? end : document.positionAt(endOffset - 1), + }); + return text; +} + +// workspaceSymbol + +export function convertNavigateToItem( + item: ts.NavigateToItem, + document: TextDocument, +) { + const info: vscode.WorkspaceSymbol = { + name: getLabel(item), + kind: convertScriptElementKind(item.kind), + location: { + uri: document.uri, + range: convertTextSpan(item.textSpan, document), + }, + }; + const kindModifiers = item.kindModifiers ? parseKindModifier(item.kindModifiers) : undefined; + if (kindModifiers?.has(PConst.KindModifiers.deprecated)) { + info.tags = [1 satisfies typeof vscode.SymbolTag.Deprecated]; + } + return info; +} + +function getLabel(item: ts.NavigateToItem) { + const label = item.name; + if (item.kind === 'method' || item.kind === 'function') { + return label + '()'; + } + return label; +} + +function convertScriptElementKind(kind: ts.ScriptElementKind): vscode.SymbolKind { + switch (kind) { + case PConst.Kind.method: return 6 satisfies typeof vscode.SymbolKind.Method; + case PConst.Kind.enum: return 10 satisfies typeof vscode.SymbolKind.Enum; + case PConst.Kind.enumMember: return 22 satisfies typeof vscode.SymbolKind.EnumMember; + case PConst.Kind.function: return 12 satisfies typeof vscode.SymbolKind.Function; + case PConst.Kind.class: return 5 satisfies typeof vscode.SymbolKind.Class; + case PConst.Kind.interface: return 11 satisfies typeof vscode.SymbolKind.Interface; + case PConst.Kind.type: return 5 satisfies typeof vscode.SymbolKind.Class; + case PConst.Kind.memberVariable: return 8 satisfies typeof vscode.SymbolKind.Field; + case PConst.Kind.memberGetAccessor: return 8 satisfies typeof vscode.SymbolKind.Field; + case PConst.Kind.memberSetAccessor: return 8 satisfies typeof vscode.SymbolKind.Field; + case PConst.Kind.variable: return 13 satisfies typeof vscode.SymbolKind.Variable; + default: return 13 satisfies typeof vscode.SymbolKind.Variable; + } +} + +// inlayHints + +export function convertInlayHint(hint: ts.InlayHint, document: TextDocument): vscode.InlayHint { + const result: vscode.InlayHint = { + position: document.positionAt(hint.position), + label: hint.text, + kind: hint.kind === 'Type' ? 1 satisfies typeof vscode.InlayHintKind.Type + : hint.kind === 'Parameter' ? 2 satisfies typeof vscode.InlayHintKind.Parameter + : undefined, + }; + result.paddingLeft = hint.whitespaceBefore; + result.paddingRight = hint.whitespaceAfter; + return result; +} + +// documentHighlight + +export function convertHighlightSpan(span: ts.HighlightSpan, document: TextDocument): vscode.DocumentHighlight { + return { + kind: span.kind === 'writtenReference' + ? 3 satisfies typeof vscode.DocumentHighlightKind.Write + : 2 satisfies typeof vscode.DocumentHighlightKind.Read, + range: convertTextSpan(span.textSpan, document), + }; +} + +// selectionRanges + +export function convertSelectionRange(range: ts.SelectionRange, document: TextDocument): vscode.SelectionRange { + return { + parent: range.parent + ? convertSelectionRange(range.parent, document) + : undefined, + range: convertTextSpan(range.textSpan, document), + }; +} + + +// rename + +export function convertFileTextChanges( + changes: readonly ts.FileTextChanges[], + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, +) { + const workspaceEdit: vscode.WorkspaceEdit = {}; + for (const change of changes) { + if (!workspaceEdit.documentChanges) { + workspaceEdit.documentChanges = []; + } + const uri = fileNameToUri(change.fileName); + let doc = getTextDocument(uri); + if (change.isNewFile) { + workspaceEdit.documentChanges.push({ kind: 'create', uri }); + } + if (!change.isNewFile) + continue; + workspaceEdit.documentChanges.push({ + textDocument: { + uri, + version: null, // fix https://github.com/johnsoncodehk/volar/issues/2025 + }, + edits: change.textChanges.map(edit => convertTextChange(edit, doc)), + }); + } + return workspaceEdit; +} + +// rename file + +export function convertRenameLocations( + newText: string, + locations: readonly ts.RenameLocation[], + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, +) { + const workspaceEdit: vscode.WorkspaceEdit = {}; + for (const location of locations) { + if (!workspaceEdit.changes) { + workspaceEdit.changes = {}; + } + const uri = fileNameToUri(location.fileName); + const doc = getTextDocument(uri); + if (!workspaceEdit.changes[uri]) { + workspaceEdit.changes[uri] = []; + } + let _newText = newText; + if (location.prefixText) + _newText = location.prefixText + _newText; + if (location.suffixText) + _newText = _newText + location.suffixText; + workspaceEdit.changes[uri].push({ + newText: _newText, + range: convertTextSpan(location.textSpan, doc), + }); + } + return workspaceEdit; +} + +// hover + +export function convertQuickInfo( + ts: typeof import('typescript'), + info: ts.QuickInfo, + document: TextDocument, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, +): vscode.Hover { + const parts: string[] = []; + const displayString = ts.displayPartsToString(info.displayParts); + const documentation = previewer.markdownDocumentation( + info.documentation ?? [], + info.tags, + fileNameToUri, + getTextDocument, + ); + if (displayString) { + parts.push(['```typescript', displayString, '```'].join('\n')); + } + if (documentation) { + parts.push(documentation); + } + const markdown: vscode.MarkupContent = { + kind: 'markdown' satisfies typeof vscode.MarkupKind.Markdown, + value: parts.join('\n\n'), + }; + return { + contents: markdown, + range: convertTextSpan(info.textSpan, document), + }; +} + +// documentSymbol + +export function convertNavTree(item: ts.NavigationTree, document: TextDocument): vscode.DocumentSymbol[] { + if (!shouldIncludeEntry(item)) { + return []; + } + let remain = item.childItems ?? []; + return item.spans.map(span => { + const childItems: ts.NavigationTree[] = []; + remain = remain.filter(child => { + const childStart = child.spans[0].start; + const childEnd = child.spans[child.spans.length - 1].start + child.spans[child.spans.length - 1].length; + if (childStart >= span.start && childEnd <= span.start + span.length) { + childItems.push(child); + return false; + } + return true; + }); + const nameSpan = item.spans.length === 1 + ? (item.nameSpan ?? span) + : span; + const fullRange = { + start: Math.min(span.start, nameSpan.start), + end: Math.max(span.start + span.length, nameSpan.start + nameSpan.length), + }; + const symbol: vscode.DocumentSymbol = { + name: item.text, + kind: getSymbolKind(item.kind), + range: convertTextSpan({ + start: fullRange.start, + length: fullRange.end - fullRange.start, + }, document), + selectionRange: convertTextSpan(nameSpan, document), + children: childItems.map(item => convertNavTree(item, document)).flat(), + }; + const kindModifiers = parseKindModifier(item.kindModifiers); + if (kindModifiers.has(PConst.KindModifiers.deprecated)) { + symbol.deprecated = true; + symbol.tags ??= []; + symbol.tags.push(1 satisfies typeof vscode.SymbolTag.Deprecated); + } + return symbol; + }); +} + +const getSymbolKind = (kind: string): vscode.SymbolKind => { + switch (kind) { + case PConst.Kind.module: return 2 satisfies typeof vscode.SymbolKind.Module; + case PConst.Kind.class: return 5 satisfies typeof vscode.SymbolKind.Class; + case PConst.Kind.enum: return 10 satisfies typeof vscode.SymbolKind.Enum; + case PConst.Kind.interface: return 11 satisfies typeof vscode.SymbolKind.Interface; + case PConst.Kind.method: return 6 satisfies typeof vscode.SymbolKind.Method; + case PConst.Kind.memberVariable: return 7 satisfies typeof vscode.SymbolKind.Property; + case PConst.Kind.memberGetAccessor: return 7 satisfies typeof vscode.SymbolKind.Property; + case PConst.Kind.memberSetAccessor: return 7 satisfies typeof vscode.SymbolKind.Property; + case PConst.Kind.variable: return 13 satisfies typeof vscode.SymbolKind.Variable; + case PConst.Kind.const: return 13 satisfies typeof vscode.SymbolKind.Variable; + case PConst.Kind.localVariable: return 13 satisfies typeof vscode.SymbolKind.Variable; + case PConst.Kind.function: return 12 satisfies typeof vscode.SymbolKind.Function; + case PConst.Kind.localFunction: return 12 satisfies typeof vscode.SymbolKind.Function; + case PConst.Kind.constructSignature: return 9 satisfies typeof vscode.SymbolKind.Constructor; + case PConst.Kind.constructorImplementation: return 9 satisfies typeof vscode.SymbolKind.Constructor; + } + return 13 satisfies typeof vscode.SymbolKind.Variable; +}; + +function shouldIncludeEntry(item: ts.NavigationTree): boolean { + if (item.kind === PConst.Kind.alias) { + return false; + } + return !!(item.text && item.text !== '' && item.text !== ''); +} + +// foldingRanges + +export function convertOutliningSpan(outliningSpan: ts.OutliningSpan, document: TextDocument): vscode.FoldingRange { + const start = document.positionAt(outliningSpan.textSpan.start); + const end = adjustFoldingEnd(start, document.positionAt(outliningSpan.textSpan.start + outliningSpan.textSpan.length), document); + return { + startLine: start.line, + endLine: end.line, + startCharacter: start.character, + endCharacter: end.character, + kind: convertOutliningSpanKind(outliningSpan.kind), + }; +} + +export function convertOutliningSpanKind(kind: ts.OutliningSpanKind): vscode.FoldingRangeKind | undefined { + switch (kind) { + case 'comment': return 'comment' satisfies typeof vscode.FoldingRangeKind.Comment; + case 'region': return 'region' satisfies typeof vscode.FoldingRangeKind.Region; + case 'imports': return 'imports' satisfies typeof vscode.FoldingRangeKind.Imports; + case 'code': + default: return undefined; + } +} + +const foldEndPairCharacters = ['}', ']', ')', '`']; + +// https://github.com/microsoft/vscode/blob/bed61166fb604e519e82e4d1d1ed839bc45d65f8/extensions/typescript-language-features/src/languageFeatures/folding.ts#L61-L73 +function adjustFoldingEnd(start: vscode.Position, end: vscode.Position, document: TextDocument) { + // workaround for #47240 + if (end.character > 0) { + const foldEndCharacter = document.getText({ + start: { line: end.line, character: end.character - 1 }, + end, + }); + if (foldEndPairCharacters.includes(foldEndCharacter)) { + const endOffset = Math.max(document.offsetAt({ line: end.line, character: 0 }) - 1, document.offsetAt(start)); + return document.positionAt(endOffset); + } + } + + return end; +} + +// formatting + +export function convertTextChange(edit: ts.TextChange, document: TextDocument): vscode.TextEdit { + return { + range: convertTextSpan(edit.span, document), + newText: edit.newText, + }; +} + +// callHierarchy + +export function convertCallHierarchyIncomingCall(item: ts.CallHierarchyIncomingCall, ctx: SharedContext): vscode.CallHierarchyIncomingCall { + const uri = ctx.fileNameToUri(item.from.file); + const document = ctx.getTextDocument(uri); + return { + from: convertCallHierarchyItem(item.from, ctx), + fromRanges: item.fromSpans + .map(span => convertTextSpan(span, document)) + .filter(notEmpty), + }; +} + +export function convertCallHierarchyOutgoingCall(item: ts.CallHierarchyOutgoingCall, fromDocument: TextDocument, ctx: SharedContext): vscode.CallHierarchyOutgoingCall { + return { + to: convertCallHierarchyItem(item.to, ctx), + fromRanges: item.fromSpans + .map(span => convertTextSpan(span, fromDocument)) + .filter(notEmpty), + }; +} + +export function convertCallHierarchyItem(item: ts.CallHierarchyItem, ctx: SharedContext): vscode.CallHierarchyItem { + const rootPath = ctx.languageService.getProgram()?.getCompilerOptions().rootDir ?? ''; + const uri = ctx.fileNameToUri(item.file); + const document = ctx.getTextDocument(uri); + const useFileName = isSourceFileItem(item); + const name = useFileName ? path.basename(item.file) : item.name; + const detail = useFileName ? path.relative(rootPath, path.dirname(item.file)) : item.containerName ?? ''; + const result: vscode.CallHierarchyItem = { + kind: typeConverters.SymbolKind.fromProtocolScriptElementKind(item.kind), + name, + detail, + uri, + range: convertTextSpan(item.span, document), + selectionRange: convertTextSpan(item.selectionSpan, document), + }; + + const kindModifiers = item.kindModifiers ? parseKindModifier(item.kindModifiers) : undefined; + if (kindModifiers?.has(PConst.KindModifiers.deprecated)) { + result.tags = [1 satisfies typeof vscode.SymbolTag.Deprecated]; + } + return result; +} + +function isSourceFileItem(item: ts.CallHierarchyItem) { + return item.kind === PConst.Kind.script || item.kind === PConst.Kind.module && item.selectionSpan.start === 0; +} + +// base + +export function convertDocumentSpanToLocation(documentSpan: ts.DocumentSpan, ctx: SharedContext): vscode.Location { + const uri = ctx.fileNameToUri(documentSpan.fileName); + const document = ctx.getTextDocument(uri); + const range = convertTextSpan(documentSpan.textSpan, document); + return { + uri, + range, + }; +} + +export function convertDefinitionInfoAndBoundSpan(info: ts.DefinitionInfoAndBoundSpan, document: TextDocument, ctx: SharedContext): vscode.LocationLink[] { + if (!info.definitions) { + return []; + } + const originSelectionRange = convertTextSpan(info.textSpan, document); + return info.definitions + .map(entry => { + const link = convertDocumentSpantoLocationLink(entry, ctx); + if (link) { + link.originSelectionRange ??= originSelectionRange; + return link; + } + }) + .filter(notEmpty); +} + +export function convertDocumentSpantoLocationLink(documentSpan: ts.DocumentSpan, ctx: SharedContext): vscode.LocationLink { + const targetUri = ctx.fileNameToUri(documentSpan.fileName); + const document = ctx.getTextDocument(targetUri); + const targetSelectionRange = convertTextSpan(documentSpan.textSpan, document); + const targetRange = documentSpan.contextSpan + ? convertTextSpan(documentSpan.contextSpan, document) + : targetSelectionRange; + const originSelectionRange = documentSpan.originalTextSpan + ? convertTextSpan(documentSpan.originalTextSpan, document) + : undefined; + return { + targetUri, + targetRange, + targetSelectionRange, + originSelectionRange, + }; +} + +export function convertTextSpan(textSpan: ts.TextSpan, document: TextDocument): vscode.Range { + return { + start: document.positionAt(textSpan.start), + end: document.positionAt(textSpan.start + textSpan.length), + }; +} diff --git a/packages/typescript/lib/utils/previewer.ts b/packages/typescript/lib/utils/previewer.ts index e893e5bc..043ef131 100644 --- a/packages/typescript/lib/utils/previewer.ts +++ b/packages/typescript/lib/utils/previewer.ts @@ -4,14 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as ts from 'typescript'; -import type { SharedContext } from '../types'; - -export interface IFilePathToResourceConverter { - /** - * Convert a typescript filepath to a VS Code resource. - */ - toResource(filepath: string): string; -} +import type { TextDocument } from 'vscode-languageserver-textdocument'; function replaceLinks(text: string): string { return text @@ -33,8 +26,8 @@ function processInlineTags(text: string): string { function getTagBodyText( tag: ts.server.protocol.JSDocTagInfo, - filePathConverter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string | undefined { if (!tag.text) { return undefined; @@ -48,7 +41,7 @@ function getTagBodyText( return '```\n' + text + '\n```'; } - const text = convertLinkTags(tag.text, filePathConverter, ctx); + const text = convertLinkTags(tag.text, fileNameToUri, getTextDocument); switch (tag.name) { case 'example': // check for caption tags, fix for #79704 @@ -76,15 +69,15 @@ function getTagBodyText( function getTagDocumentation( tag: ts.server.protocol.JSDocTagInfo, - filePathConverter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string | undefined { switch (tag.name) { case 'augments': case 'extends': case 'param': case 'template': - const body = (convertLinkTags(tag.text, filePathConverter, ctx)).split(/^(\S+)\s*-?\s*/); + const body = (convertLinkTags(tag.text, fileNameToUri, getTextDocument)).split(/^(\S+)\s*-?\s*/); if (body?.length === 3) { const param = body[1]; const doc = body[2]; @@ -98,7 +91,7 @@ function getTagDocumentation( // Generic tag const label = `*@${tag.name}*`; - const text = getTagBodyText(tag, filePathConverter, ctx); + const text = getTagBodyText(tag, fileNameToUri, getTextDocument); if (!text) { return label; } @@ -107,10 +100,10 @@ function getTagDocumentation( export function plainWithLinks( parts: readonly ts.server.protocol.SymbolDisplayPart[] | string, - filePathConverter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string { - return processInlineTags(convertLinkTags(parts, filePathConverter, ctx)); + return processInlineTags(convertLinkTags(parts, fileNameToUri, getTextDocument)); } /** @@ -118,8 +111,8 @@ export function plainWithLinks( */ function convertLinkTags( parts: readonly ts.server.protocol.SymbolDisplayPart[] | string | undefined, - filePathConverter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string { if (!parts) { return ''; @@ -144,29 +137,24 @@ function convertLinkTags( fileName: string, textSpan: { start: number, length: number; }, }; - const fileDoc = ctx.getTextDocument(ctx.uriToFileName(_target.fileName)); - if (fileDoc) { - const start = fileDoc.positionAt(_target.textSpan.start); - const end = fileDoc.positionAt(_target.textSpan.start + _target.textSpan.length); - target = { - file: _target.fileName, - start: { - line: start.line + 1, - offset: start.character + 1, - }, - end: { - line: end.line + 1, - offset: end.character + 1, - }, - }; - } - else { - target = undefined; - } + const fileDoc = getTextDocument(fileNameToUri(_target.fileName)); + const start = fileDoc.positionAt(_target.textSpan.start); + const end = fileDoc.positionAt(_target.textSpan.start + _target.textSpan.length); + target = { + file: _target.fileName, + start: { + line: start.line + 1, + offset: start.character + 1, + }, + end: { + line: end.line + 1, + offset: end.character + 1, + }, + }; } if (target) { - const link = filePathConverter.toResource(target.file) + '#' + `L${target.start.line},${target.start.offset}`; + const link = fileNameToUri(target.file) + '#' + `L${target.start.line},${target.start.offset}`; out.push(`[${text}](${link})`); } else { @@ -203,34 +191,34 @@ function convertLinkTags( export function tagsMarkdownPreview( tags: readonly ts.JSDocTagInfo[], - filePathConverter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string { - return tags.map(tag => getTagDocumentation(tag, filePathConverter, ctx)).join(' \n\n'); + return tags.map(tag => getTagDocumentation(tag, fileNameToUri, getTextDocument)).join(' \n\n'); } export function markdownDocumentation( documentation: ts.server.protocol.SymbolDisplayPart[] | string | undefined, tags: ts.JSDocTagInfo[] | undefined, - filePathConverter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string { - return addMarkdownDocumentation('', documentation, tags, filePathConverter, ctx); + return addMarkdownDocumentation('', documentation, tags, fileNameToUri, getTextDocument); } export function addMarkdownDocumentation( out: string, documentation: ts.server.protocol.SymbolDisplayPart[] | string | undefined, tags: ts.JSDocTagInfo[] | undefined, - converter: IFilePathToResourceConverter, - ctx: SharedContext, + fileNameToUri: (fileName: string) => string, + getTextDocument: (uri: string) => TextDocument, ): string { if (documentation) { - out += plainWithLinks(documentation, converter, ctx); + out += plainWithLinks(documentation, fileNameToUri, getTextDocument); } if (tags) { - const tagsPreview = tagsMarkdownPreview(tags, converter, ctx); + const tagsPreview = tagsMarkdownPreview(tags, fileNameToUri, getTextDocument); if (tagsPreview) { out += '\n\n' + tagsPreview; } diff --git a/packages/typescript/lib/utils/transforms.ts b/packages/typescript/lib/utils/transforms.ts deleted file mode 100644 index 498a7177..00000000 --- a/packages/typescript/lib/utils/transforms.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type * as vscode from '@volar/language-service'; -import type * as ts from 'typescript'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import type { SharedContext } from '../types'; - -export function entriesToLocations( - entries: { fileName: string, textSpan: ts.TextSpan; }[], - ctx: SharedContext, -) { - return entries - .map(entry => entryToLocation(entry, ctx)) - .filter((location): location is vscode.Location => !!location); -} -export function entryToLocation( - entry: { fileName: string, textSpan: ts.TextSpan; }, - ctx: SharedContext, -) { - const entryUri = ctx.fileNameToUri(entry.fileName); - const doc = ctx.getTextDocument(entryUri); - if (!doc) return; - const range: vscode.Range = { - start: doc.positionAt(entry.textSpan.start), - end: doc.positionAt(entry.textSpan.start + entry.textSpan.length), - }; - const location: vscode.Location = { uri: entryUri, range }; - return location; -} -export function entriesToLocationLinks( - entries: T[], - ctx: SharedContext, -): vscode.LocationLink[] { - const locations: vscode.LocationLink[] = []; - for (const entry of entries) { - const entryUri = ctx.fileNameToUri(entry.fileName); - const doc = ctx.getTextDocument(entryUri); - if (!doc) continue; - const targetSelectionRange: vscode.Range = { - start: doc.positionAt(entry.textSpan.start), - end: doc.positionAt(entry.textSpan.start + entry.textSpan.length), - }; - const targetRange: vscode.Range = entry.contextSpan ? { - start: doc.positionAt(entry.contextSpan.start), - end: doc.positionAt(entry.contextSpan.start + entry.contextSpan.length), - } : targetSelectionRange; - const originSelectionRange: vscode.Range | undefined = entry.originalTextSpan ? { - start: doc.positionAt(entry.originalTextSpan.start), - end: doc.positionAt(entry.originalTextSpan.start + entry.originalTextSpan.length), - } : undefined; - const location: vscode.LocationLink = { - targetUri: entryUri, - targetRange, - targetSelectionRange, - originSelectionRange, - }; - locations.push(location); - } - return locations; -} -export function boundSpanToLocationLinks( - info: ts.DefinitionInfoAndBoundSpan, - originalDoc: TextDocument, - ctx: SharedContext, -): vscode.LocationLink[] { - const locations: vscode.LocationLink[] = []; - if (!info.definitions) return locations; - const originSelectionRange: vscode.Range = { - start: originalDoc.positionAt(info.textSpan.start), - end: originalDoc.positionAt(info.textSpan.start + info.textSpan.length), - }; - for (const entry of info.definitions) { - const entryUri = ctx.fileNameToUri(entry.fileName); - const doc = ctx.getTextDocument(entryUri); - if (!doc) continue; - const targetSelectionRange: vscode.Range = { - start: doc.positionAt(entry.textSpan.start), - end: doc.positionAt(entry.textSpan.start + entry.textSpan.length), - }; - const targetRange: vscode.Range = entry.contextSpan ? { - start: doc.positionAt(entry.contextSpan.start), - end: doc.positionAt(entry.contextSpan.start + entry.contextSpan.length), - } : targetSelectionRange; - const location: vscode.LocationLink = { - targetUri: entryUri, - targetRange, - targetSelectionRange, - originSelectionRange, - }; - locations.push(location); - } - return locations; -} diff --git a/packages/typescript/semantic.ts b/packages/typescript/semantic.ts new file mode 100644 index 00000000..4ad5e0d0 --- /dev/null +++ b/packages/typescript/semantic.ts @@ -0,0 +1,892 @@ +import type { + CancellationToken, + CompletionItemKind, + DocumentHighlight, + FileChangeType, + InsertTextFormat, + Location, + ParameterInformation, + Result, + ServiceContext, + ServicePlugin, + ServicePluginInstance, + SignatureHelpTriggerKind, + SignatureInformation, + VirtualCode, + WorkspaceEdit +} from '@volar/language-service'; +import { getDocumentRegistry } from '@volar/typescript'; +import * as path from 'path-browserify'; +import * as semver from 'semver'; +import type * as ts from 'typescript'; +import * as tsWithImportCache from 'typescript-auto-import-cache'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import { getFormatCodeSettings } from './lib/configs/getFormatCodeSettings'; +import { getUserPreferences } from './lib/configs/getUserPreferences'; +import { getConfigTitle, isJsonDocument, isTsDocument, notEmpty, safeCall } from './lib/shared'; +import { + applyCompletionEntryDetails, + convertCallHierarchyIncomingCall, + convertCallHierarchyItem, + convertCallHierarchyOutgoingCall, + convertCompletionInfo, + convertDefinitionInfoAndBoundSpan, + convertDiagnostic, + convertDocumentSpanToLocation, + convertDocumentSpantoLocationLink, + convertFileTextChanges, + convertHighlightSpan, + convertInlayHint, + convertNavigateToItem, + convertQuickInfo, + convertRenameLocations, + convertSelectionRange, + convertTextSpan, + getLineText +} from './lib/utils/lspConverters'; +import { snippetForFunctionCall } from './lib/utils/snippetForFunctionCall'; +import * as codeActions from './lib/semanticFeatures/codeAction'; +import * as codeActionResolve from './lib/semanticFeatures/codeActionResolve'; +import * as semanticTokens from './lib/semanticFeatures/semanticTokens'; +import type { SharedContext } from './lib/semanticFeatures/types'; + +export interface Provide { + 'typescript/languageService': () => ts.LanguageService; + 'typescript/languageServiceHost': () => ts.LanguageServiceHost; +} + +export interface CompletionItemData { + uri: string, + fileName: string, + offset: number, + originalItem: { + name: ts.CompletionEntry['name'], + source: ts.CompletionEntry['source'], + data: ts.CompletionEntry['data'], + labelDetails: ts.CompletionEntry['labelDetails'], + }; +} + +export function create( + ts: typeof import('typescript'), + { + isValidationEnabled = async (document, context) => { + return await context.env.getConfiguration?.(getConfigTitle(document) + '.validate.enable') ?? true; + }, + isSuggestionsEnabled = async (document, context) => { + return await context.env.getConfiguration?.(getConfigTitle(document) + '.suggest.enabled') ?? true; + }, + }: { + isValidationEnabled?(document: TextDocument, context: ServiceContext): Result; + isSuggestionsEnabled?(document: TextDocument, context: ServiceContext): Result; + } = {}, +): ServicePlugin { + return { + name: 'typescript-semantic', + triggerCharacters: getBasicTriggerCharacters(ts.version), + signatureHelpTriggerCharacters: ['(', ',', '<'], + signatureHelpRetriggerCharacters: [')'], + create(context): ServicePluginInstance { + if (!context.language.typescript) { + return {}; + } + const { sys, languageServiceHost } = context.language.typescript; + const created = tsWithImportCache.createLanguageService( + ts, + sys, + languageServiceHost, + proxiedHost => ts.createLanguageService(proxiedHost, getDocumentRegistry(ts, sys.useCaseSensitiveFileNames, languageServiceHost.getCurrentDirectory())), + ); + const { languageService } = created; + + if (created.setPreferences && context.env.getConfiguration) { + + updatePreferences(); + context.env.onDidChangeConfiguration?.(updatePreferences); + + async function updatePreferences() { + const preferences = await context.env.getConfiguration?.('typescript.preferences'); + if (preferences) { + created.setPreferences?.(preferences); + } + } + } + + if (created.projectUpdated) { + + const sourceScriptNames = new Set(); + const normalizeFileName = sys.useCaseSensitiveFileNames + ? (id: string) => id + : (id: string) => id.toLowerCase(); + + updateSourceScriptFileNames(); + + context.env.onDidChangeWatchedFiles?.((params) => { + const someFileCreateOrDeiete = params.changes.some(change => change.type !== 2 satisfies typeof FileChangeType.Changed); + if (someFileCreateOrDeiete) { + updateSourceScriptFileNames(); + } + for (const change of params.changes) { + const fileName = context.env.typescript!.uriToFileName(change.uri); + if (sourceScriptNames.has(normalizeFileName(fileName))) { + created.projectUpdated?.(languageServiceHost.getCurrentDirectory()); + } + } + }); + + function updateSourceScriptFileNames() { + sourceScriptNames.clear(); + for (const fileName of languageServiceHost.getScriptFileNames()) { + const uri = context.env.typescript!.fileNameToUri(fileName); + const sourceFile = context.language.files.get(uri); + if (sourceFile?.generated) { + const tsCode = sourceFile.generated.languagePlugin.typescript?.getScript(sourceFile.generated.code); + if (tsCode) { + sourceScriptNames.add(normalizeFileName(fileName)); + } + } + else if (sourceFile) { + sourceScriptNames.add(normalizeFileName(fileName)); + } + } + } + } + + const ctx: SharedContext = { + ...context, + languageServiceHost, + languageService, + uriToFileName(uri) { + const virtualScript = getVirtualScriptByUri(uri); + if (virtualScript) { + return virtualScript.fileName; + } + return context.env.typescript!.uriToFileName(uri); + }, + fileNameToUri(fileName) { + + const uri = context.env.typescript!.fileNameToUri(fileName); + const sourceFile = context.language.files.get(uri); + const extraScript = context.language.typescript!.getExtraScript(fileName); + + let virtualCode = extraScript?.code; + + if (!virtualCode && sourceFile?.generated?.languagePlugin.typescript) { + const mainScript = sourceFile.generated.languagePlugin.typescript.getScript(sourceFile.generated.code); + if (mainScript) { + virtualCode = mainScript.code; + } + } + if (virtualCode) { + const sourceFile = context.language.files.getByVirtualCode(virtualCode); + return context.documents.getVirtualCodeUri(sourceFile.id, virtualCode.id); + } + + return uri; + }, + getTextDocument(uri) { + const virtualCode = context.documents.getVirtualCodeByUri(uri)[0]; + if (virtualCode) { + return context.documents.get(uri, virtualCode.languageId, virtualCode.snapshot); + } + const sourceFile = context.language.files.get(uri); + if (sourceFile) { + return context.documents.get(uri, sourceFile.languageId, sourceFile.snapshot); + } + throw new Error(`getTextDocument: uri not found: ${uri}`); + }, + }; + const getCodeActions = codeActions.register(ctx); + const doCodeActionResolve = codeActionResolve.register(ctx); + const getDocumentSemanticTokens = semanticTokens.register(ts, ctx); + + /* typescript-language-features is hardcode true */ + const renameInfoOptions = { allowRenameOfImportPath: true }; + + return { + + provide: { + 'typescript/languageService': () => languageService, + 'typescript/languageServiceHost': () => languageServiceHost, + }, + + dispose() { + languageService.dispose(); + }, + + async provideCompletionItems(document, position, completeContext, token) { + + if (!isSemanticDocument(document)) + return; + + if (!await isSuggestionsEnabled(document, context)) + return; + + return await worker(token, async () => { + + const preferences = await getUserPreferences(ctx, document); + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const info = safeCall(() => ctx.languageService.getCompletionsAtPosition(fileName, offset, { + ...preferences, + triggerCharacter: completeContext.triggerCharacter as ts.CompletionsTriggerCharacter, + triggerKind: completeContext.triggerKind, + })); + if (info) { + return convertCompletionInfo( + ts, + info, + document, + position, + tsEntry => ({ + uri: document.uri, + fileName, + offset, + originalItem: { + name: tsEntry.name, + source: tsEntry.source, + data: tsEntry.data, + labelDetails: tsEntry.labelDetails, + }, + }), + ); + } + }); + }, + + async resolveCompletionItem(item, token) { + return await worker(token, async () => { + const data: CompletionItemData | undefined = item.data; + if (!data) { + return item; + } + const { fileName, offset } = data; + const document = ctx.getTextDocument(data.uri); + const [formatOptions, preferences] = await Promise.all([ + getFormatCodeSettings(ctx, document), + getUserPreferences(ctx, document), + ]); + const details = safeCall(() => ctx.languageService.getCompletionEntryDetails(fileName, offset, data.originalItem.name, formatOptions, data.originalItem.source, preferences, data.originalItem.data)); + if (!details) { + return item; + } + if (data.originalItem.labelDetails) { + item.labelDetails ??= {}; + Object.assign(item.labelDetails, data.originalItem.labelDetails); + } + applyCompletionEntryDetails( + ts, + item, + details, + document, + ctx.fileNameToUri, + ctx.getTextDocument, + ); + const useCodeSnippetsOnMethodSuggest = await ctx.env.getConfiguration?.(getConfigTitle(document) + '.suggest.completeFunctionCalls') ?? false; + const useCodeSnippet = useCodeSnippetsOnMethodSuggest + && ( + item.kind === 3 satisfies typeof CompletionItemKind.Function + || item.kind === 2 satisfies typeof CompletionItemKind.Method + ); + if (useCodeSnippet) { + const shouldCompleteFunction = isValidFunctionCompletionContext(ctx.languageService, fileName, offset, document); + if (shouldCompleteFunction) { + const { snippet, parameterCount } = snippetForFunctionCall( + { + insertText: item.insertText ?? item.textEdit?.newText, // insertText is dropped by LSP in some case: https://github.com/microsoft/vscode-languageserver-node/blob/9b742021fb04ad081aa3676a9eecf4fa612084b4/client/src/common/codeConverter.ts#L659-L664 + label: item.label, + }, + details.displayParts, + ); + if (item.textEdit) { + item.textEdit.newText = snippet; + } + if (item.insertText) { + item.insertText = snippet; + } + item.insertTextFormat = 2 satisfies typeof InsertTextFormat.Snippet; + if (parameterCount > 0) { + //Fix for https://github.com/microsoft/vscode/issues/104059 + //Don't show parameter hints if "editor.parameterHints.enabled": false + // if (await getConfiguration('editor.parameterHints.enabled', document.uri)) { + // item.command = { + // title: 'triggerParameterHints', + // command: 'editor.action.triggerParameterHints', + // }; + // } + } + } + } + return item; + + function isValidFunctionCompletionContext( + client: ts.LanguageService, + filepath: string, + offset: number, + document: TextDocument, + ): boolean { + // Workaround for https://github.com/microsoft/TypeScript/issues/12677 + // Don't complete function calls inside of destructive assignments or imports + try { + const response = client.getQuickInfoAtPosition(filepath, offset); + if (response) { + switch (response.kind) { + case 'var': + case 'let': + case 'const': + case 'alias': + return false; + } + } + } catch { + // Noop + } + + // Don't complete function call if there is already something that looks like a function call + // https://github.com/microsoft/vscode/issues/18131 + const position = document.positionAt(offset); + const after = getLineText(document, position.line).slice(position.character); + return after.match(/^[a-z_$0-9]*\s*\(/gi) === null; + } + }) ?? item; + }, + + provideRenameRange(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const renameInfo = safeCall(() => ctx.languageService.getRenameInfo(fileName, offset, renameInfoOptions)); + if (!renameInfo) { + return; + } + if (!renameInfo.canRename) { + return { message: renameInfo.localizedErrorMessage }; + } + return convertTextSpan(renameInfo.triggerSpan, document); + }); + }, + + provideRenameEdits(document, position, newName, token) { + + if (!isSemanticDocument(document, true)) + return; + + return worker(token, async () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const renameInfo = safeCall(() => ctx.languageService.getRenameInfo(fileName, offset, renameInfoOptions)); + if (!renameInfo?.canRename) { + return; + } + if (renameInfo.fileToRename) { + const [formatOptions, preferences] = await Promise.all([ + getFormatCodeSettings(ctx, document), + getUserPreferences(ctx, document), + ]); + return renameFile(renameInfo.fileToRename, newName, formatOptions, preferences); + } + + const { providePrefixAndSuffixTextForRename } = await getUserPreferences(ctx, document); + const entries = ctx.languageService.findRenameLocations(fileName, offset, false, false, providePrefixAndSuffixTextForRename); + if (!entries) { + return; + } + return convertRenameLocations(newName, entries, ctx.fileNameToUri, ctx.getTextDocument); + + function renameFile( + fileToRename: string, + newName: string, + formatOptions: ts.FormatCodeSettings, + preferences: ts.UserPreferences, + ): WorkspaceEdit | undefined { + // Make sure we preserve file extension if none provided + if (!path.extname(newName)) { + newName += path.extname(fileToRename); + } + const dirname = path.dirname(fileToRename); + const newFilePath = path.join(dirname, newName); + const response = safeCall(() => ctx.languageService.getEditsForFileRename(fileToRename, newFilePath, formatOptions, preferences)); + if (!response) { + return; + } + const edits = convertFileTextChanges(response, ctx.fileNameToUri, ctx.getTextDocument); + if (!edits.documentChanges) { + edits.documentChanges = []; + } + edits.documentChanges.push({ + kind: 'rename', + oldUri: ctx.fileNameToUri(fileToRename), + newUri: ctx.fileNameToUri(newFilePath), + }); + return edits; + } + }); + }, + + provideCodeActions(document, range, context, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + return getCodeActions(document, range, context); + }); + }, + + async resolveCodeAction(codeAction, token) { + return await worker(token, () => { + return doCodeActionResolve(codeAction); + }) ?? codeAction; + }, + + provideInlayHints(document, range, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, async () => { + const preferences = await getUserPreferences(ctx, document); + const fileName = ctx.uriToFileName(document.uri); + const start = document.offsetAt(range.start); + const end = document.offsetAt(range.end); + const inlayHints = safeCall(() => + 'provideInlayHints' in ctx.languageService + ? ctx.languageService.provideInlayHints(fileName, { start, length: end - start }, preferences) + : [] + ); + if (!inlayHints) { + return []; + } + return inlayHints.map(hint => convertInlayHint(hint, document)); + }); + }, + + provideCallHierarchyItems(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const calls = safeCall(() => ctx.languageService.prepareCallHierarchy(fileName, offset)); + if (!calls) { + return []; + } + const items = Array.isArray(calls) ? calls : [calls]; + return items.map(item => convertCallHierarchyItem(item, ctx)); + }); + }, + + async provideCallHierarchyIncomingCalls(item, token) { + return await worker(token, () => { + const document = ctx.getTextDocument(item.uri); + const fileName = ctx.uriToFileName(item.uri); + const offset = document.offsetAt(item.selectionRange.start); + const calls = safeCall(() => ctx.languageService.provideCallHierarchyIncomingCalls(fileName, offset)); + if (!calls) { + return []; + } + const items = Array.isArray(calls) ? calls : [calls]; + return items.map(item => convertCallHierarchyIncomingCall(item, ctx)); + }) ?? []; + }, + + async provideCallHierarchyOutgoingCalls(item, token) { + return await worker(token, () => { + const document = ctx.getTextDocument(item.uri); + const fileName = ctx.uriToFileName(item.uri); + const offset = document.offsetAt(item.selectionRange.start); + const calls = safeCall(() => ctx.languageService.provideCallHierarchyOutgoingCalls(fileName, offset)); + if (!calls) { + return []; + } + const items = Array.isArray(calls) ? calls : [calls]; + return items.map(item => convertCallHierarchyOutgoingCall(item, document, ctx)); + }) ?? []; + }, + + provideDefinition(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const info = safeCall(() => ctx.languageService.getDefinitionAndBoundSpan(fileName, offset)); + if (!info) { + return []; + } + return convertDefinitionInfoAndBoundSpan(info, document, ctx); + }); + }, + + provideTypeDefinition(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const entries = safeCall(() => ctx.languageService.getTypeDefinitionAtPosition(fileName, offset)); + if (!entries) { + return []; + } + return entries.map(entry => convertDocumentSpantoLocationLink(entry, ctx)); + }); + }, + + async provideDiagnostics(document, token) { + return provideDiagnosticsWorker(document, token, 'syntactic'); + }, + + async provideSemanticDiagnostics(document, token) { + return provideDiagnosticsWorker(document, token, 'semantic'); + }, + + provideHover(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const info = safeCall(() => ctx.languageService.getQuickInfoAtPosition(fileName, offset)); + if (!info) { + return; + } + return convertQuickInfo(ts, info, document, ctx.fileNameToUri, ctx.getTextDocument); + }); + }, + + provideImplementation(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const entries = safeCall(() => ctx.languageService.getImplementationAtPosition(fileName, offset)); + if (!entries) { + return []; + } + return entries.map(entry => convertDocumentSpantoLocationLink(entry, ctx)); + }); + }, + + provideReferences(document, position, referenceContext, token) { + + if (!isSemanticDocument(document, true)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const references = safeCall(() => ctx.languageService.findReferences(fileName, offset)); + if (!references) { + return []; + } + const result: Location[] = []; + for (const reference of references) { + if (referenceContext.includeDeclaration) { + const definition = convertDocumentSpanToLocation(reference.definition, ctx); + if (definition) { + result.push(definition); + } + } + for (const referenceEntry of reference.references) { + const reference = convertDocumentSpanToLocation(referenceEntry, ctx); + if (reference) { + result.push(reference); + } + } + } + return result; + }); + }, + + provideFileReferences(document, token) { + + if (!isSemanticDocument(document, true)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const entries = safeCall(() => ctx.languageService.getFileReferences(fileName)); + if (!entries) { + return []; + } + return entries.map(entry => convertDocumentSpanToLocation(entry, ctx)); + }); + }, + + provideDocumentHighlights(document, position, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const highlights = safeCall(() => ctx.languageService.getDocumentHighlights(fileName, offset, [fileName])); + if (!highlights) { + return []; + } + const results: DocumentHighlight[] = []; + for (const highlight of highlights) { + for (const span of highlight.highlightSpans) { + results.push(convertHighlightSpan(span, document)); + } + } + return results; + }); + }, + + provideDocumentSemanticTokens(document, range, legend, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + return getDocumentSemanticTokens(document, range, legend); + }); + }, + + provideWorkspaceSymbols(query, token) { + return worker(token, () => { + const items = safeCall(() => ctx.languageService.getNavigateToItems(query)); + if (!items) { + return []; + } + return items + .filter(item => item.containerName || item.kind !== 'alias') + .map(item => convertNavigateToItem(item, ctx.getTextDocument(ctx.fileNameToUri(item.fileName)))) + .filter(notEmpty); + }); + }, + + provideFileRenameEdits(oldUri, newUri, token) { + return worker(token, async () => { + const document = ctx.getTextDocument(oldUri); + const [formatOptions, preferences] = await Promise.all([ + getFormatCodeSettings(ctx, document), + getUserPreferences(ctx, document), + ]); + + const fileToRename = ctx.uriToFileName(oldUri); + const newFilePath = ctx.uriToFileName(newUri); + const response = safeCall(() => ctx.languageService.getEditsForFileRename(fileToRename, newFilePath, formatOptions, preferences)); + if (!response?.length) { + return; + } + + return convertFileTextChanges(response, ctx.fileNameToUri, ctx.getTextDocument); + }); + }, + + provideSelectionRanges(document, positions, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + return positions + .map(position => { + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const range = safeCall(() => ctx.languageService.getSmartSelectionRange(fileName, offset)); + if (!range) { + return; + } + return convertSelectionRange(range, document); + }) + .filter(notEmpty); + }); + }, + + provideSignatureHelp(document, position, context, token) { + + if (!isSemanticDocument(document)) + return; + + return worker(token, () => { + const options: ts.SignatureHelpItemsOptions = {}; + if (context?.triggerKind === 1 satisfies typeof SignatureHelpTriggerKind.Invoked) { + options.triggerReason = { + kind: 'invoked' + }; + } + else if (context?.triggerKind === 2 satisfies typeof SignatureHelpTriggerKind.TriggerCharacter) { + options.triggerReason = { + kind: 'characterTyped', + triggerCharacter: context.triggerCharacter as ts.SignatureHelpTriggerCharacter, + }; + } + else if (context?.triggerKind === 3 satisfies typeof SignatureHelpTriggerKind.ContentChange) { + options.triggerReason = { + kind: 'retrigger', + triggerCharacter: context.triggerCharacter as ts.SignatureHelpRetriggerCharacter, + }; + } + + const fileName = ctx.uriToFileName(document.uri); + const offset = document.offsetAt(position); + const helpItems = safeCall(() => ctx.languageService.getSignatureHelpItems(fileName, offset, options)); + if (!helpItems) { + return; + } + + return { + activeSignature: helpItems.selectedItemIndex, + activeParameter: helpItems.argumentIndex, + signatures: helpItems.items.map(item => { + const signature: SignatureInformation = { + label: '', + documentation: undefined, + parameters: [] + }; + signature.label += ts.displayPartsToString(item.prefixDisplayParts); + item.parameters.forEach((p, i, a) => { + const label = ts.displayPartsToString(p.displayParts); + const parameter: ParameterInformation = { + label, + documentation: ts.displayPartsToString(p.documentation) + }; + signature.label += label; + signature.parameters!.push(parameter); + if (i < a.length - 1) { + signature.label += ts.displayPartsToString(item.separatorDisplayParts); + } + }); + signature.label += ts.displayPartsToString(item.suffixDisplayParts); + return signature; + }), + }; + }); + }, + }; + + async function provideDiagnosticsWorker(document: TextDocument, token: CancellationToken, mode: 'syntactic' | 'semantic') { + + if (!isSemanticDocument(document)) + return; + + if (!await isValidationEnabled(document, context)) + return; + + return await worker(token, () => { + const fileName = ctx.uriToFileName(document.uri); + const program = ctx.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + if (!program || !sourceFile) { + return []; + } + const token: ts.CancellationToken = { + isCancellationRequested() { + return ctx.language.typescript?.languageServiceHost.getCancellationToken?.().isCancellationRequested() ?? false; + }, + throwIfCancellationRequested() { }, + }; + if (mode === 'syntactic') { + const syntacticDiagnostics = safeCall(() => program.getSyntacticDiagnostics(sourceFile, token)) ?? []; + const suggestionDiagnostics = safeCall(() => ctx.languageService.getSuggestionDiagnostics(fileName)) ?? []; + + return [...syntacticDiagnostics, ...suggestionDiagnostics] + .map(diagnostic => convertDiagnostic(diagnostic, document, ctx.fileNameToUri, ctx.getTextDocument)) + .filter(notEmpty); + } + else if (mode === 'semantic') { + const semanticDiagnostics = safeCall(() => program.getSemanticDiagnostics(sourceFile, token)) ?? []; + const declarationDiagnostics = getEmitDeclarations(program.getCompilerOptions()) + ? safeCall(() => program.getDeclarationDiagnostics(sourceFile, token)) ?? [] + : []; + + return [...semanticDiagnostics, ...declarationDiagnostics] + .map(diagnostic => convertDiagnostic(diagnostic, document, ctx.fileNameToUri, ctx.getTextDocument)) + .filter(notEmpty); + } + }); + } + + function getEmitDeclarations(compilerOptions: ts.CompilerOptions): boolean { + return !!(compilerOptions.declaration || compilerOptions.composite); + } + + function isSemanticDocument(document: TextDocument, withJson = false) { + const virtualScript = getVirtualScriptByUri(document.uri); + if (virtualScript) { + return true; + } + if (withJson && isJsonDocument(document)) { + return true; + } + return isTsDocument(document); + } + + async function worker(token: CancellationToken, fn: () => T): Promise | undefined> { + let result: Awaited | undefined; + let oldSysVersion: number | undefined; + let newSysVersion = await sys.sync?.(); + do { + oldSysVersion = newSysVersion; + try { + result = await fn(); + } catch (err) { + console.warn(err); + break; + } + newSysVersion = await sys.sync?.(); + } while (newSysVersion !== oldSysVersion && !token.isCancellationRequested); + return result; + } + + function getVirtualScriptByUri(uri: string): { + fileName: string; + code: VirtualCode; + } | undefined { + const [virtualCode, sourceFile] = context.documents.getVirtualCodeByUri(uri); + if (virtualCode && sourceFile.generated?.languagePlugin.typescript) { + const { getScript, getExtraScripts } = sourceFile.generated?.languagePlugin.typescript; + const sourceFileName = context.env.typescript!.uriToFileName(sourceFile.id); + if (getScript(sourceFile.generated.code)?.code === virtualCode) { + return { + fileName: sourceFileName, + code: virtualCode, + }; + } + for (const extraScript of getExtraScripts?.(sourceFileName, sourceFile.generated.code) ?? []) { + if (extraScript.code === virtualCode) { + return extraScript; + } + } + } + } + }, + }; +} + +function getBasicTriggerCharacters(tsVersion: string) { + + const triggerCharacters = ['.', '"', '\'', '`', '/', '<']; + + // https://github.com/microsoft/vscode/blob/8e65ae28d5fb8b3c931135da1a41edb9c80ae46f/extensions/typescript-language-features/src/languageFeatures/completions.ts#L811-L833 + if (semver.lt(tsVersion, '3.1.0') || semver.gte(tsVersion, '3.2.0')) { + triggerCharacters.push('@'); + } + if (semver.gte(tsVersion, '3.8.1')) { + triggerCharacters.push('#'); + } + if (semver.gte(tsVersion, '4.3.0')) { + triggerCharacters.push(' '); + } + + return triggerCharacters; +} diff --git a/packages/typescript/syntactic.ts b/packages/typescript/syntactic.ts new file mode 100644 index 00000000..c783ba52 --- /dev/null +++ b/packages/typescript/syntactic.ts @@ -0,0 +1,131 @@ +import type { + Result, + ServiceContext, + ServicePlugin, + ServicePluginInstance +} from '@volar/language-service'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; +import { getFormatCodeSettings } from './lib/configs/getFormatCodeSettings'; +import { getConfigTitle, isTsDocument, safeCall } from './lib/shared'; +import { + convertNavTree, + convertOutliningSpan, + convertTextChange +} from './lib/utils/lspConverters'; +import { getLanguageService } from './lib/syntacticLanguageService'; + +export function create( + ts: typeof import('typescript'), + { + isFormattingEnabled = async (document, context) => { + return await context.env.getConfiguration?.(getConfigTitle(document) + '.format.enable') ?? true; + }, + isAutoClosingTagsEnabled = async (document, context) => { + return await context.env.getConfiguration?.(getConfigTitle(document) + '.autoClosingTags') ?? true; + }, + }: { + isFormattingEnabled?(document: TextDocument, context: ServiceContext): Result; + isAutoClosingTagsEnabled?(document: TextDocument, context: ServiceContext): Result; + } = {}, +): ServicePlugin { + return { + name: 'typescript-syntactic', + // https://github.com/microsoft/vscode/blob/ce119308e8fd4cd3f992d42b297588e7abe33a0c/extensions/typescript-language-features/src/languageFeatures/formatting.ts#L99 + autoFormatTriggerCharacters: [';', '}', '\n'], + create(context): ServicePluginInstance { + + return { + + async provideAutoInsertionEdit(document, position, lastChange) { + if ( + (document.languageId === 'javascriptreact' || document.languageId === 'typescriptreact') + && lastChange.text.endsWith('>') + && await isAutoClosingTagsEnabled(document, context) + ) { + const { languageService, fileName } = getLanguageService(ts, document); + const close = languageService.getJsxClosingTagAtPosition(fileName, document.offsetAt(position)); + if (close) { + return '$0' + close.newText; + } + } + }, + + provideFoldingRanges(document) { + + if (!isTsDocument(document)) + return; + + const { languageService, fileName } = getLanguageService(ts, document); + const outliningSpans = safeCall(() => languageService.getOutliningSpans(fileName)); + if (!outliningSpans) { + return []; + } + return outliningSpans.map(span => convertOutliningSpan(span, document)); + }, + + provideDocumentSymbols(document) { + + if (!isTsDocument(document)) + return; + + const { languageService, fileName } = getLanguageService(ts, document); + const barItems = safeCall(() => languageService.getNavigationTree(fileName)); + if (!barItems) return []; + + // The root represents the file. Ignore this when showing in the UI + return barItems.childItems + ?.map(item => convertNavTree(item, document)) + .flat() + ?? []; + }, + + async provideDocumentFormattingEdits(document, range, options, codeOptions) { + + if (!isTsDocument(document)) + return; + + if (!await isFormattingEnabled(document, context)) + return; + + const tsOptions = await getFormatCodeSettings(context, document, options); + if (codeOptions) { + tsOptions.baseIndentSize = codeOptions.initialIndentLevel * options.tabSize; + } + const { languageService, fileName } = getLanguageService(ts, document); + const scriptEdits = range + ? safeCall(() => languageService.getFormattingEditsForRange( + fileName, + document.offsetAt(range.start), + document.offsetAt(range.end), + tsOptions, + )) + : safeCall(() => languageService.getFormattingEditsForDocument(fileName, tsOptions)); + if (!scriptEdits) { + return []; + } + return scriptEdits.map(edit => convertTextChange(edit, document)); + }, + + async provideOnTypeFormattingEdits(document, position, key, options, codeOptions) { + + if (!isTsDocument(document)) + return; + + if (!await isFormattingEnabled(document, context)) + return; + + const tsOptions = await getFormatCodeSettings(context, document, options); + if (codeOptions) { + tsOptions.baseIndentSize = codeOptions.initialIndentLevel * options.tabSize; + } + const { languageService, fileName } = getLanguageService(ts, document); + const scriptEdits = safeCall(() => languageService.getFormattingEditsAfterKeystroke(fileName, document.offsetAt(position), key, tsOptions)); + if (!scriptEdits) { + return []; + } + return scriptEdits.map(edit => convertTextChange(edit, document)); + }, + }; + }, + }; +}