diff --git a/README.md b/README.md index 7288844..9cbca0f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ | `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` | | `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | | `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` | +| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `false` | diff --git a/eslint.config.ts b/eslint.config.ts index 332251b..e9cfbff 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -7,10 +7,13 @@ export default defineConfig( }, { files: ['src/commands/**'], + ignores: ['**/index.ts'], rules: { 'no-restricted-imports': ['error', { paths: [{ name: 'reactive-vscode', + allowImportNames: ['useCommand', 'useCommands', 'useTextEditorCommand', 'useTextEditorCommands'], + allowTypeImports: true, message: 'Do not use reactive-vscode composables in command handlers. Use vscode API directly.', }], }], diff --git a/package.json b/package.json index c9d1936..9411817 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,11 @@ "type": "boolean", "default": true, "description": "Show warnings when a dependency uses a dist tag" + }, + "npmx.versionLens.enabled": { + "type": "boolean", + "default": false, + "description": "Show version lens (CodeLens) for package dependencies" } } }, diff --git a/src/commands/internal/index.ts b/src/commands/internal/index.ts new file mode 100644 index 0000000..d0826ed --- /dev/null +++ b/src/commands/internal/index.ts @@ -0,0 +1,9 @@ +import { internalCommands } from '#state' +import { useTextEditorCommands } from 'reactive-vscode' +import { replaceText } from './replace-text' + +export function useInternalCommands() { + useTextEditorCommands({ + [internalCommands.replaceText]: replaceText, + }) +} diff --git a/src/commands/internal/replace-text.ts b/src/commands/internal/replace-text.ts new file mode 100644 index 0000000..77f9049 --- /dev/null +++ b/src/commands/internal/replace-text.ts @@ -0,0 +1,9 @@ +import type { TextEditorCommandCallback } from 'reactive-vscode' +import type { Range, TextEditor, TextEditorEdit } from 'vscode' + +export const replaceText: TextEditorCommandCallback = (_: TextEditor, edit: TextEditorEdit, range?: Range, text?: string) => { + if (!range || !text) + return + + edit.replace(range, text) +} diff --git a/src/index.ts b/src/index.ts index 5067e36..2293aac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ import { VERSION_TRIGGER_CHARACTERS } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' import { Disposable, languages } from 'vscode' +import { useInternalCommands } from './commands/internal' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { extractorEntries } from './extractors' import { commands, displayName, version } from './generated-meta' import { useCodeActions } from './providers/code-actions' +import { useCodeLens } from './providers/code-lens' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { useDiagnostics } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -40,9 +42,10 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + useInternalCommands() useDiagnostics() - useCodeActions() + useCodeLens() useCommands({ [commands.openInBrowser]: openInBrowser, diff --git a/src/providers/code-lens/index.ts b/src/providers/code-lens/index.ts new file mode 100644 index 0000000..d1dd00c --- /dev/null +++ b/src/providers/code-lens/index.ts @@ -0,0 +1,18 @@ +import { extractorEntries } from '#extractors' +import { config } from '#state' +import { watchEffect } from 'reactive-vscode' +import { Disposable, languages } from 'vscode' +import { VersionCodeLensProvider } from './version' + +export function useCodeLens() { + watchEffect((onCleanup) => { + if (!config.versionLens.enabled) + return + + const disposables = extractorEntries.map(({ pattern, extractor }) => + languages.registerCodeLensProvider({ pattern }, new VersionCodeLensProvider(extractor)), + ) + + onCleanup(() => Disposable.from(...disposables).dispose()) + }) +} diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts new file mode 100644 index 0000000..1af22b7 --- /dev/null +++ b/src/providers/code-lens/version.ts @@ -0,0 +1,84 @@ +import type { DependencyInfo, Extractor } from '#types/extractor' +import type { CodeLensProvider, TextDocument } from 'vscode' +import { internalCommands } from '#state' +import { getPackageInfo } from '#utils/api/package' +import { formatVersion, getUpdateType, isSupportedProtocol, parseVersion } from '#utils/version' +import { debounce } from 'perfect-debounce' +import { CodeLens, EventEmitter } from 'vscode' + +const dataMap = new WeakMap() + +export class VersionCodeLensProvider implements CodeLensProvider { + extractor: T + private readonly onDidChangeCodeLensesEmitter = new EventEmitter() + readonly onDidChangeCodeLenses = this.onDidChangeCodeLensesEmitter.event + private readonly scheduleRefresh = debounce(() => { + this.onDidChangeCodeLensesEmitter.fire() + }, 100, { leading: false, trailing: true }) + + constructor(extractor: T) { + this.extractor = extractor + } + + provideCodeLenses(document: TextDocument): CodeLens[] { + const root = this.extractor.parse(document) + if (!root) + return [] + + const deps = this.extractor.getDependenciesInfo(root) + const lenses: CodeLens[] = [] + + for (const dep of deps) { + const parsed = parseVersion(dep.version) + if (!parsed || !isSupportedProtocol(parsed.protocol)) + continue + + const versionRange = this.extractor.getNodeRange(document, dep.versionNode) + const lens = new CodeLens(versionRange) + dataMap.set(lens, dep) + lenses.push(lens) + } + + return lenses + } + + resolveCodeLens(lens: CodeLens) { + const dep = dataMap.get(lens) + if (!dep) + return lens + + const parsed = parseVersion(dep.version) + if (!parsed) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + + const pkg = getPackageInfo(dep.name) + if (pkg instanceof Promise) { + lens.command = { title: '$(sync~spin) checking...', command: '' } + pkg.finally(() => this.scheduleRefresh()) + return lens + } + + const latest = pkg?.distTags.latest + if (!latest) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + + const updateType = getUpdateType(parsed.semver, latest) + + if (updateType === 'none') { + lens.command = { title: '$(check) latest', command: '' } + } else { + const newVersion = formatVersion({ ...parsed, semver: latest }) + lens.command = { + title: `$(arrow-up) ${newVersion} (${updateType})`, + command: internalCommands.replaceText, + arguments: [lens.range, newVersion], + } + } + + return lens + } +} diff --git a/src/state.ts b/src/state.ts index 99e6068..499d6d4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -5,3 +5,7 @@ import { displayName, scopedConfigs } from './generated-meta' export const config = defineConfig(scopedConfigs.scope) export const logger = defineLogger(displayName) + +export const internalCommands = { + replaceText: 'npmx.internal.replaceText', +} diff --git a/src/utils/api/package.ts b/src/utils/api/package.ts index b4fd30e..c05ddb6 100644 --- a/src/utils/api/package.ts +++ b/src/utils/api/package.ts @@ -18,16 +18,14 @@ export const getPackageInfo = memoize>(async const pkg = await getVersions(name, { metadata: true, throw: false, + retry: 3, }) if ('error' in pkg) { logger.warn(`Fetching package info for ${name} error: ${JSON.stringify(pkg)}`) // Return null to trigger a cache hit - if (pkg.status === 404) - return null - - throw pkg + return null } logger.info(`Fetched package info for ${name}`) diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index 63f9102..875542c 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -16,7 +16,7 @@ interface MemoizeEntry { expiresAt?: number } -type MemoizeReturn = R extends Promise ? Promise : R | undefined +type MemoizeReturn = R extends Promise ? Promise | V | undefined : R | undefined export function memoize(fn: (params: P) => V, options: MemoizeOptions

= {}): (params: P) => MemoizeReturn { const { diff --git a/src/utils/version.ts b/src/utils/version.ts index 6b0a5b0..2dddcda 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -49,7 +49,52 @@ export function parseVersion(rawVersion: string): ParsedVersion | null { return { protocol, prefix, semver } } +/** related to: https://semver.org/spec/v2.0.0-rc.2.html */ +const CORE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/ +type CoreVersion = [major: number, minor: number, patch: number] + +function stripBuildMetadata(version: string): string { + const idx = version.indexOf('+') + return idx === -1 ? version : version.slice(0, idx) +} + +function parseCoreVersion(version: string): CoreVersion | null { + const match = version.match(CORE_VERSION_PATTERN) + if (!match) + return null + + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +export type UpdateType = 'major' | 'minor' | 'patch' | 'prerelease' | 'none' + +export function getUpdateType(current: string, latest: string): UpdateType { + current = stripBuildMetadata(current) + latest = stripBuildMetadata(latest) + + const cur = parseCoreVersion(current) + const lat = parseCoreVersion(latest) + + if (!cur || !lat) + return 'none' + + if (lat[0] !== cur[0]) + return lat[0] > cur[0] ? 'major' : 'none' + + if (lat[1] !== cur[1]) + return lat[1] > cur[1] ? 'minor' : 'none' + + if (lat[2] !== cur[2]) + return lat[2] > cur[2] ? 'patch' : 'none' + + if (current !== latest && current.includes('-') && !latest.includes('-')) + return 'prerelease' + + return 'none' +} + export function getPrereleaseId(version: string): string | null { + version = stripBuildMetadata(version) const idx = version.indexOf('-') if (idx === -1) return null @@ -75,8 +120,14 @@ function comparePrereleasePrecedence(a: string, b: string): number { const numA = Number(partsA[i]) const numB = Number(partsB[i]) - if (!Number.isNaN(numA) && !Number.isNaN(numB)) { - return numA - numB + const isNumA = !Number.isNaN(numA) + const isNumB = !Number.isNaN(numB) + + if (isNumA && isNumB) { + if (numA !== numB) + return numA - numB + } else if (isNumA !== isNumB) { + return isNumA ? -1 : 1 } else if (partsA[i] !== partsB[i]) { return partsA[i] < partsB[i] ? -1 : 1 } @@ -85,11 +136,26 @@ function comparePrereleasePrecedence(a: string, b: string): number { return 0 } +function parseSemver(v: string) { + const hyphen = v.indexOf('-') + const core = hyphen === -1 ? v : v.slice(0, hyphen) + const pre = hyphen === -1 ? undefined : v.slice(hyphen + 1) + + return { + core, + pre, + } +} + export function lt(a: string, b: string): boolean { - const [coreA, preA] = a.split('-', 2) - const [coreB, preB] = b.split('-', 2) - const partsA = coreA.split('.').map(Number) - const partsB = coreB.split('.').map(Number) + a = stripBuildMetadata(a) + b = stripBuildMetadata(b) + + const { core: coreA, pre: preA } = parseSemver(a) + const { core: coreB, pre: preB } = parseSemver(b) + + const partsA = parseCoreVersion(coreA)! + const partsB = parseCoreVersion(coreB)! for (let i = 0; i < 3; i++) { const diff = (partsA[i] || 0) - (partsB[i] || 0) if (diff !== 0) diff --git a/tests/utils/version.test.ts b/tests/utils/version.test.ts index 0522063..ac21be8 100644 --- a/tests/utils/version.test.ts +++ b/tests/utils/version.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getPrereleaseId, lt, parseVersion } from '../../src/utils/version' +import { getPrereleaseId, getUpdateType, lt, parseVersion } from '../../src/utils/version' describe('parseVersion', () => { it('should parse plain version', () => { @@ -85,6 +85,11 @@ describe('getPrereleaseId', () => { it('should handle prerelease without dots', () => { expect(getPrereleaseId('1.0.0-canary')).toBe('canary') }) + + it('should ignore build metadata', () => { + expect(getPrereleaseId('1.0.0-beta.1+build')).toBe('beta') + expect(getPrereleaseId('1.0.0+build')).toBeNull() + }) }) describe('lt', () => { @@ -126,4 +131,39 @@ describe('lt', () => { expect(lt('1.0.0-beta', '1.0.0-beta.1')).toBe(true) expect(lt('1.0.0-beta.1', '1.0.0-beta')).toBe(false) }) + + it('should ignore build metadata for precedence', () => { + expect(lt('1.0.0+build1', '1.0.0+build2')).toBe(false) + expect(lt('1.0.0-alpha+001', '1.0.0')).toBe(true) + expect(lt('1.0.0-alpha+001', '1.0.0-alpha.1')).toBe(true) + }) + + it('should rank numeric identifiers lower than non-numeric', () => { + expect(lt('1.0.0-1', '1.0.0-alpha')).toBe(true) + expect(lt('1.0.0-alpha', '1.0.0-1')).toBe(false) + }) + + it('should follow rc.2 spec precedence example', () => { + expect(lt('1.0.0-alpha', '1.0.0-alpha.1')).toBe(true) + expect(lt('1.0.0-alpha.1', '1.0.0-beta.2')).toBe(true) + expect(lt('1.0.0-beta.2', '1.0.0-beta.11')).toBe(true) + expect(lt('1.0.0-beta.11', '1.0.0-rc.1')).toBe(true) + expect(lt('1.0.0-rc.1', '1.0.0')).toBe(true) + }) + + it('should handle prerelease identifiers containing hyphens', () => { + expect(lt('1.0.0-alpha-beta', '1.0.0-alpha-gamma')).toBe(true) + expect(lt('1.0.0-alpha-gamma', '1.0.0-alpha-beta')).toBe(false) + }) +}) + +describe('getUpdateType', () => { + it('should ignore build metadata', () => { + expect(getUpdateType('1.0.0+build', '2.0.0+build')).toBe('major') + expect(getUpdateType('1.0.0+build1', '1.0.0+build2')).toBe('none') + }) + + it('should detect prerelease with build metadata', () => { + expect(getUpdateType('1.0.0-alpha+001', '1.0.0')).toBe('prerelease') + }) })