diff --git a/extensions/vscode/src/index.ts b/extensions/vscode/src/index.ts index 3ef4b97..2760596 100644 --- a/extensions/vscode/src/index.ts +++ b/extensions/vscode/src/index.ts @@ -9,7 +9,6 @@ import { openInBrowser } from './commands/open-in-browser' import { useCodeActions } from './providers/code-actions' import { useCompletionItem } from './providers/completion-item' import { useDecorators } from './providers/decorators' -import { useDefinition } from './providers/definition' import { useDiagnostics } from './providers/diagnostics' import { useDocumentLink } from './providers/document-link' import { logger } from './state' @@ -28,7 +27,6 @@ export const { activate, deactivate } = defineExtension((ctx) => { useDecorators() useCodeActions() useDocumentLink() - useDefinition() useCommands({ [commands.openInBrowser]: openInBrowser, diff --git a/extensions/vscode/src/providers/completion-item/catalog.ts b/extensions/vscode/src/providers/completion-item/catalog.ts deleted file mode 100644 index f113efd..0000000 --- a/extensions/vscode/src/providers/completion-item/catalog.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { CompletionItemProvider, Position, TextDocument } from 'vscode' -import { getResolvedDependencyByOffset, getWorkspaceContext } from '#core/workspace' -import { CompletionItem, CompletionItemKind } from 'vscode' - -export class CatalogCompletionItemProvider implements CompletionItemProvider { - static triggers = [':'] - - async provideCompletionItems(document: TextDocument, position: Position) { - const offset = document.offsetAt(position) - const info = await getResolvedDependencyByOffset(document.uri, offset) - if (!info?.rawSpec.startsWith('catalog:')) - return - - const ctx = await getWorkspaceContext(document.uri) - if (!ctx) - return - - const catalogs = await ctx.getCatalogs() - - if (!catalogs) - return - - return Object.entries(catalogs).flatMap(([name, catalog]) => { - const version = catalog[info.resolvedName] - if (!version) - return [] - - const item = new CompletionItem(name, CompletionItemKind.Value) - item.detail = version - return [item] - }) - } -} diff --git a/extensions/vscode/src/providers/completion-item/index.ts b/extensions/vscode/src/providers/completion-item/index.ts index 944e5f2..209d3c7 100644 --- a/extensions/vscode/src/providers/completion-item/index.ts +++ b/extensions/vscode/src/providers/completion-item/index.ts @@ -1,8 +1,7 @@ import { config } from '#state' -import { PACKAGE_JSON_PATTERN, SUPPORTED_DOCUMENT_PATTERN } from '#utils/constants' +import { SUPPORTED_DOCUMENT_PATTERN } from '#utils/constants' import { watchEffect } from 'reactive-vscode' import { languages } from 'vscode' -import { CatalogCompletionItemProvider } from './catalog' import { VersionCompletionItemProvider } from './version' export function useCompletionItem() { @@ -18,14 +17,4 @@ export function useCompletionItem() { onCleanup(() => disposable.dispose()) }) - - watchEffect((onCleanup) => { - const disposable = languages.registerCompletionItemProvider( - { pattern: PACKAGE_JSON_PATTERN }, - new CatalogCompletionItemProvider(), - ...CatalogCompletionItemProvider.triggers, - ) - - onCleanup(() => disposable.dispose()) - }) } diff --git a/extensions/vscode/src/providers/definition/catalog.ts b/extensions/vscode/src/providers/definition/catalog.ts deleted file mode 100644 index a8881c5..0000000 --- a/extensions/vscode/src/providers/definition/catalog.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DefinitionProvider, Position, TextDocument } from 'vscode' -import { getResolvedDependencyByOffset, getWorkspaceContext } from '#core/workspace' -import { offsetRangeToRange } from '#utils/ast' -import { normalizeCatalogName } from 'npmx-language-core/utils' -import { Location, workspace } from 'vscode' - -export class CatalogDefinitionProvider implements DefinitionProvider { - async provideDefinition(document: TextDocument, position: Position) { - const offset = document.offsetAt(position) - const info = await getResolvedDependencyByOffset(document.uri, offset) - if (!info?.rawSpec.startsWith('catalog:')) - return - - const ctx = await getWorkspaceContext(document.uri) - if (!ctx?.workspaceFilePath) - return - - const dependencies = (await ctx.loadWorkspaceFileInfo(ctx.workspaceFilePath))?.dependencies - if (!dependencies) - return - - const target = dependencies.find( - (dep) => - dep.rawName === info.resolvedName - && dep.categoryName != null && info.categoryName != null - && normalizeCatalogName(dep.categoryName) === normalizeCatalogName(info.categoryName), - ) - if (!target) - return - - const workspaceFileUri = document.uri.with({ path: ctx.workspaceFilePath }) - const workspaceDocument = await workspace.openTextDocument(workspaceFileUri) - - return new Location( - workspaceFileUri, - offsetRangeToRange(workspaceDocument, target.specRange), - ) - } -} diff --git a/extensions/vscode/src/providers/definition/index.ts b/extensions/vscode/src/providers/definition/index.ts deleted file mode 100644 index 4aa79f2..0000000 --- a/extensions/vscode/src/providers/definition/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PACKAGE_JSON_PATTERN } from '#utils/constants' -import { useDisposable } from 'reactive-vscode' -import { languages } from 'vscode' -import { CatalogDefinitionProvider } from './catalog' - -export function useDefinition() { - useDisposable( - languages.registerDefinitionProvider({ pattern: PACKAGE_JSON_PATTERN }, new CatalogDefinitionProvider()), - ) -} diff --git a/extensions/vscode/src/utils/constants.ts b/extensions/vscode/src/utils/constants.ts index eaa62f0..e2a4bbc 100644 --- a/extensions/vscode/src/utils/constants.ts +++ b/extensions/vscode/src/utils/constants.ts @@ -1,6 +1,5 @@ import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from 'npmx-language-core/constants' -export const PACKAGE_JSON_PATTERN = `**/${PACKAGE_JSON_BASENAME}` export const SUPPORTED_DOCUMENT_PATTERN = `**/{${PACKAGE_JSON_BASENAME},${PNPM_WORKSPACE_BASENAME},${YARN_WORKSPACE_BASENAME}}` export const PRERELEASE_PATTERN = /-.+/ diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index ee872d9..d8d7f6f 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -1,9 +1,11 @@ import type { LanguageServicePlugin } from '@volar/language-service' import type { IWorkspaceState } from './types' +import { create as createNpmxCatalogService } from './plugins/catalog' import { create as createNpmxHoverService } from './plugins/hover' export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): LanguageServicePlugin[] { return [ + createNpmxCatalogService(workspace), createNpmxHoverService(workspace), ] } diff --git a/packages/language-service/src/plugins/catalog.ts b/packages/language-service/src/plugins/catalog.ts new file mode 100644 index 0000000..41db272 --- /dev/null +++ b/packages/language-service/src/plugins/catalog.ts @@ -0,0 +1,135 @@ +import type { CompletionItemKind, CompletionList, LanguageServicePlugin, LanguageServicePluginInstance, LocationLink } from '@volar/language-service' +import type { DependencyInfo } from 'npmx-language-core/workspace' +import type { IWorkspaceState } from '../types' +import { isPackageManifest, normalizeCatalogName } from 'npmx-language-core/utils' +import { URI } from 'vscode-uri' +import { getResolvedDependencyAtOffset } from '../utils/range' + +export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { + function getDependencyFileUri(documentUri: string): URI | undefined { + const uri = URI.parse(documentUri) + if (uri.scheme !== 'file' || !isPackageManifest(uri.path)) + return + + return uri + } + + async function getCatalogDependency(documentUri: string, offset: number): Promise { + const dependencies = await workspaceState.getResolvedDependencies(documentUri) + if (!dependencies) + return + + const dependency = getResolvedDependencyAtOffset(dependencies, offset) + if (!dependency?.rawSpec.startsWith('catalog:')) + return + + return dependency + } + + function matchesCatalogDependency(candidate: DependencyInfo, dependency: DependencyInfo): boolean { + return candidate.rawName === dependency.resolvedName + && candidate.categoryName != null + && dependency.categoryName != null + && normalizeCatalogName(candidate.categoryName) === normalizeCatalogName(dependency.categoryName) + } + + return { + name: 'npmx-catalog', + capabilities: { + completionProvider: { + triggerCharacters: [':'], + }, + definitionProvider: true, + }, + create(context): LanguageServicePluginInstance { + return { + async provideCompletionItems(document, position): Promise { + const dependencyFileUri = getDependencyFileUri(document.uri) + if (!dependencyFileUri) + return + + const offset = document.offsetAt(position) + const dependency = await getCatalogDependency(document.uri, offset) + if (!dependency) + return + + const workspaceContext = await workspaceState.getWorkspaceContext(document.uri) + if (!workspaceContext) + return + + const catalogs = await workspaceContext.getCatalogs() + if (!catalogs) + return + + const items: CompletionList['items'] = [] + + for (const [name, catalog] of Object.entries(catalogs)) { + const version = catalog[dependency.resolvedName] + if (!version) + continue + + items.push({ + label: name, + kind: 12 satisfies typeof CompletionItemKind.Value, + detail: version, + }) + } + + return { isIncomplete: false, items } + }, + + async provideDefinition(document, position): Promise { + const dependencyFileUri = getDependencyFileUri(document.uri) + if (!dependencyFileUri) + return + + const offset = document.offsetAt(position) + const dependency = await getCatalogDependency(document.uri, offset) + if (!dependency) + return + + const workspaceContext = await workspaceState.getWorkspaceContext(document.uri) + if (!workspaceContext?.workspaceFilePath) + return + + const workspaceFileInfo = await workspaceContext.loadWorkspaceFileInfo(workspaceContext.workspaceFilePath) + if (!workspaceFileInfo) + return + + const targetDependency = workspaceFileInfo.dependencies.find((candidate) => + matchesCatalogDependency(candidate, dependency), + ) + if (!targetDependency) + return + + const workspaceFileUri = dependencyFileUri.with({ path: workspaceContext.workspaceFilePath }) + const sourceScript = context.language.scripts.get(workspaceFileUri) + if (!sourceScript) + return + + const workspaceDocument = context.documents.get(sourceScript.id, sourceScript.languageId, sourceScript.snapshot) + + const [targetStart, targetEnd] = targetDependency.specRange + const originStart = document.positionAt(dependency.specRange[0]) + const originEnd = document.positionAt(dependency.specRange[1]) + + return [{ + targetUri: workspaceFileUri.toString(), + targetRange: { + start: workspaceDocument.positionAt(targetStart), + end: workspaceDocument.positionAt(targetEnd), + }, + targetSelectionRange: { + start: workspaceDocument.positionAt(targetStart), + end: workspaceDocument.positionAt(targetEnd), + }, + originSelectionRange: { + start: originStart, + end: originEnd, + }, + }] + }, + } + }, + } +}