diff --git a/extensions/vscode/src/index.ts b/extensions/vscode/src/index.ts index 3b2dadb..96edea6 100644 --- a/extensions/vscode/src/index.ts +++ b/extensions/vscode/src/index.ts @@ -8,7 +8,6 @@ import { launch } from './client' import { addToIgnore } from './commands/add-to-ignore' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' -import { useCompletionItem } from './providers/completion-item' import { useDecorators } from './providers/decorators' import { useDocumentLink } from './providers/document-link' import { logger } from './state' @@ -22,7 +21,6 @@ export const { activate, deactivate } = defineExtension((ctx) => { useWorkspaceContext() - useCompletionItem() useDecorators() useDocumentLink() diff --git a/extensions/vscode/src/providers/completion-item/index.ts b/extensions/vscode/src/providers/completion-item/index.ts deleted file mode 100644 index 209d3c7..0000000 --- a/extensions/vscode/src/providers/completion-item/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { config } from '#state' -import { SUPPORTED_DOCUMENT_PATTERN } from '#utils/constants' -import { watchEffect } from 'reactive-vscode' -import { languages } from 'vscode' -import { VersionCompletionItemProvider } from './version' - -export function useCompletionItem() { - watchEffect((onCleanup) => { - if (config.completion.version === 'off') - return - - const disposable = languages.registerCompletionItemProvider( - { pattern: SUPPORTED_DOCUMENT_PATTERN }, - new VersionCompletionItemProvider(), - ...VersionCompletionItemProvider.triggers, - ) - - onCleanup(() => disposable.dispose()) - }) -} diff --git a/extensions/vscode/src/providers/completion-item/version.ts b/extensions/vscode/src/providers/completion-item/version.ts deleted file mode 100644 index 743358a..0000000 --- a/extensions/vscode/src/providers/completion-item/version.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { CompletionItemProvider, Position, TextDocument } from 'vscode' -import { getResolvedDependencyByOffset } from '#core/workspace' -import { config } from '#state' -import { offsetRangeToRange } from '#utils/ast' -import { PRERELEASE_PATTERN } from '#utils/constants' -import { formatUpgradeVersion } from '#utils/version' -import { CompletionItem, CompletionItemKind } from 'vscode' - -export class VersionCompletionItemProvider implements CompletionItemProvider { - static triggers = [':', '^', '~', '.', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] - - async provideCompletionItems(document: TextDocument, position: Position) { - const offset = document.offsetAt(position) - const info = await getResolvedDependencyByOffset(document.uri, offset) - if (!info) - return - - if (info.resolvedProtocol !== 'npm') - return - - const pkg = await info.packageInfo() - if (!pkg) - return - - const items: CompletionItem[] = [] - - for (const version in pkg.versionsMeta) { - const meta = pkg.versionsMeta[version]! - - if (meta.deprecated != null) - continue - - if (config.completion.excludePrerelease && PRERELEASE_PATTERN.test(version)) - continue - - if (config.completion.version === 'provenance-only' && !meta.provenance) - continue - - const text = formatUpgradeVersion(info, version) - const item = new CompletionItem(text, CompletionItemKind.Value) - - item.range = offsetRangeToRange(document, info.specRange) - item.insertText = text - - const tag = pkg.versionToTag.get(version) - if (tag) - item.detail = tag - - items.push(item) - } - - return items - } -} diff --git a/extensions/vscode/src/utils/version.test.ts b/extensions/vscode/src/utils/version.test.ts deleted file mode 100644 index 7c3cfec..0000000 --- a/extensions/vscode/src/utils/version.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { DependencyInfo } from 'npmx-language-core/workspace' -import { describe, expect, it } from 'vitest' -import { formatUpgradeVersion } from './version' - -describe('formatUpgradeVersion', () => { - it.each([ - [['^1.0.0'], '2.0.0', '^2.0.0'], - [['~1.0.0'], '1.1.0', '~1.1.0'], - [['1.0.0'], '2.0.0', '2.0.0'], - [['1.x'], '2.0.0', '^2.0.0'], - [['1.0.x'], '1.1.0', '~1.1.0'], - [['>=1.0.0'], '2.0.0', '>=2.0.0'], - [['*'], '2.0.0', '*'], - [[''], '2.0.0', '*'], - [['x'], '2.0.0', '*'], - [['^1.0.0', 'npm:foo@^1.0.0'], '2.0.0', '^2.0.0'], - [['1.0.0', 'npm:foo@1.0.0'], '2.0.0', '2.0.0'], - [['*', 'npm:foo@*'], '2.0.0', '*'], - [['^1.0.0', 'npm:foo@^1.0.0', 'my-foo'], '2.0.0', 'npm:foo@^2.0.0'], - ])('should preserve $0', ([resolvedSpec, rawSpec = resolvedSpec, rawName = 'foo', protocol = 'npm'], target, expected) => { - expect( - formatUpgradeVersion({ protocol, rawName, rawSpec, resolvedName: 'foo', resolvedSpec } as DependencyInfo, target), - ).toBe(expected) - }) -}) diff --git a/extensions/vscode/src/utils/version.ts b/extensions/vscode/src/utils/version.ts deleted file mode 100644 index 45d3f84..0000000 --- a/extensions/vscode/src/utils/version.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { DependencyInfo } from 'npmx-language-core/workspace' -import { formatPackageId } from 'npmx-language-core/utils' - -const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<'] - -function getVersionRangePrefix(v: string): string { - const ver = v.trim().toLowerCase() - - if (ver === '*' || ver === '') - return '*' - if (ver[0] === '~' || ver[0] === '^') - return ver[0] - for (const leading of RANGE_PREFIXES) { - if (ver.startsWith(leading)) - return leading - } - if (ver.includes('x')) { - const parts = ver.split('.') - if (parts[0] === 'x') - return '*' - if (parts[1] === 'x') - return '^' - if (parts[2] === 'x') - return '~' - } - - return '' -} - -const PROTOCOL_PATTERN = /^[a-z]+:/ - -export function formatUpgradeVersion(dep: DependencyInfo, target: string): string { - const { rawName, rawSpec, resolvedName, resolvedSpec, protocol } = dep - - const isAlias = resolvedName !== rawName - const prefix = getVersionRangePrefix(resolvedSpec) - const result = prefix === '*' ? '*' : `${prefix}${target}` - - if (!isAlias) - return result - - const declaredProtocol = PROTOCOL_PATTERN.test(rawSpec) ? protocol : null - if (!declaredProtocol) - return result - - return `${declaredProtocol}:${formatPackageId(resolvedName, result)}` -} diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index d787c58..c451fd4 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -3,11 +3,13 @@ import type { IWorkspaceState } from './types' import { create as createNpmxCatalogService } from './plugins/catalog' import { create as createNpmxDiagnosticsService } from './plugins/diagnostics' import { create as createNpmxHoverService } from './plugins/hover' +import { create as createNpmxVersionCompletionService } from './plugins/version-completion' export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): LanguageServicePlugin[] { return [ createNpmxCatalogService(workspace), createNpmxDiagnosticsService(workspace), createNpmxHoverService(workspace), + createNpmxVersionCompletionService(workspace), ] } diff --git a/packages/language-service/src/plugins/version-completion.ts b/packages/language-service/src/plugins/version-completion.ts new file mode 100644 index 0000000..902a0fd --- /dev/null +++ b/packages/language-service/src/plugins/version-completion.ts @@ -0,0 +1,75 @@ +import type { CompletionItemKind, CompletionList, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service' +import type { IWorkspaceState } from '../types' +import { isDependencyFile } from 'npmx-language-core/utils' +import { URI } from 'vscode-uri' +import { getConfig } from '../config' +import { getResolvedDependencyAtOffset } from '../utils/range' +import { formatUpgradeVersion } from '../utils/version' + +const PRERELEASE_PATTERN = /-.+/ + +export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { + return { + name: 'npmx-version-completion', + capabilities: { + completionProvider: { + triggerCharacters: [':', '^', '~', '.'], + }, + }, + create(context): LanguageServicePluginInstance { + return { + async provideCompletionItems(document, position): Promise { + const completionVersion = await getConfig(context, 'npmx.completion.version') + if (completionVersion === 'off') + return + + const uri = URI.parse(document.uri) + if (uri.scheme !== 'file' || !isDependencyFile(uri.path)) + return + + const dependencies = await workspaceState.getResolvedDependencies(document.uri) + if (!dependencies) + return + + const offset = document.offsetAt(position) + const dep = getResolvedDependencyAtOffset(dependencies, offset) + if (!dep || dep.resolvedProtocol !== 'npm') + return + + const pkg = await dep.packageInfo() + if (!pkg) + return + + const excludePrerelease = await getConfig(context, 'npmx.completion.excludePrerelease') + const items: CompletionList['items'] = [] + + for (const version in pkg.versionsMeta) { + const meta = pkg.versionsMeta[version]! + + if (meta.deprecated != null) + continue + + if (excludePrerelease && PRERELEASE_PATTERN.test(version)) + continue + + if (completionVersion === 'provenance-only' && !meta.provenance) + continue + + const text = formatUpgradeVersion(dep, version) + + const tag = pkg.versionToTag.get(version) + + items.push({ + label: text, + kind: 12 satisfies typeof CompletionItemKind.Value, + insertText: text, + detail: tag, + }) + } + + return { isIncomplete: false, items } + }, + } + }, + } +}