Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- configs -->

Expand Down
3 changes: 3 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
}],
}],
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
9 changes: 9 additions & 0 deletions src/commands/internal/index.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
9 changes: 9 additions & 0 deletions src/commands/internal/replace-text.ts
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -40,9 +42,10 @@ export const { activate, deactivate } = defineExtension(() => {
onCleanup(() => Disposable.from(...disposables).dispose())
})

useInternalCommands()
useDiagnostics()

useCodeActions()
useCodeLens()

useCommands({
[commands.openInBrowser]: openInBrowser,
Expand Down
18 changes: 18 additions & 0 deletions src/providers/code-lens/index.ts
Original file line number Diff line number Diff line change
@@ -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())
})
}
84 changes: 84 additions & 0 deletions src/providers/code-lens/version.ts
Original file line number Diff line number Diff line change
@@ -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<CodeLens, DependencyInfo>()

export class VersionCodeLensProvider<T extends Extractor> implements CodeLensProvider {
extractor: T
private readonly onDidChangeCodeLensesEmitter = new EventEmitter<void>()
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
}
}
4 changes: 4 additions & 0 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ import { displayName, scopedConfigs } from './generated-meta'
export const config = defineConfig<NestedScopedConfigs>(scopedConfigs.scope)

export const logger = defineLogger(displayName)

export const internalCommands = {
replaceText: 'npmx.internal.replaceText',
}
6 changes: 2 additions & 4 deletions src/utils/api/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(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}`)
Expand Down
2 changes: 1 addition & 1 deletion src/utils/memoize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface MemoizeEntry<V> {
expiresAt?: number
}

type MemoizeReturn<R> = R extends Promise<infer V> ? Promise<V | undefined> : R | undefined
type MemoizeReturn<R> = R extends Promise<infer V> ? Promise<V | undefined> | V | undefined : R | undefined

export function memoize<P, V>(fn: (params: P) => V, options: MemoizeOptions<P> = {}): (params: P) => MemoizeReturn<V> {
const {
Expand Down
78 changes: 72 additions & 6 deletions src/utils/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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)
Expand Down
42 changes: 41 additions & 1 deletion tests/utils/version.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})