From baf0850c46504f7e7f2db507a7752db97299dca8 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 4 Feb 2026 00:46:30 +0800 Subject: [PATCH 1/7] feat(utils): unify `parseVersion` --- src/providers/completion-item/version.ts | 8 ++- .../diagnostics/rules/deprecation.ts | 14 ++-- .../diagnostics/rules/vulnerability.ts | 13 ++-- src/providers/hover/npmx.ts | 22 ++++--- src/utils/package.ts | 39 ++++++++--- tests/package.test.ts | 66 +++++++++++++++++++ 6 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 tests/package.test.ts diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 133a5d5..52d7953 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -2,7 +2,7 @@ import type { Extractor } from '#types/extractor' import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' -import { extractVersionPrefix } from '#utils/package' +import { parseVersion } from '#utils/package' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { @@ -32,7 +32,11 @@ export class VersionCompletionItemProvider implements Compl if (!pkg) return - const prefix = extractVersionPrefix(version) + const parsed = parseVersion(version) + if (!parsed) + return + + const { prefix } = parsed const items: CompletionItem[] = [] diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 3a8ae6b..849997f 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -1,22 +1,26 @@ import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' -import { extractVersion } from '#utils/package' +import { parseVersion } from '#utils/package' import { DiagnosticSeverity, Uri } from 'vscode' export const checkDeprecation: DiagnosticRule = (dep, pkg) => { - const exactVersion = extractVersion(dep.version) - const versionInfo = pkg.versionsMeta[exactVersion] + const parsed = parseVersion(dep.version) + if (!parsed) + return + + const { version } = parsed + const versionInfo = pkg.versionsMeta[version] if (!versionInfo?.deprecated) return return { node: dep.versionNode, - message: `${dep.name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`, + message: `${dep.name} v${version} has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { value: 'deprecation', - target: Uri.parse(npmxPackageUrl(dep.name, exactVersion)), + target: Uri.parse(npmxPackageUrl(dep.name, version)), }, } } diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 89d40cc..c79121d 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -2,7 +2,7 @@ import type { OsvSeverityLevel } from '#utils/api/vulnerability' import type { DiagnosticRule } from '..' import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability' import { npmxPackageUrl } from '#utils/links' -import { extractVersion } from '#utils/package' +import { parseVersion } from '#utils/package' import { DiagnosticSeverity, Uri } from 'vscode' const DIAGNOSTIC_MAPPING: Record, DiagnosticSeverity> = { @@ -13,13 +13,16 @@ const DIAGNOSTIC_MAPPING: Record, Diagnosti } export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { - const exactVersion = extractVersion(dep.version) - const versionInfo = pkg.versionsMeta[exactVersion] + const parsed = parseVersion(dep.version) + if (!parsed) + return + const { version } = parsed + const versionInfo = pkg.versionsMeta[version] if (!versionInfo) return - const result = await getVulnerability({ name: dep.name, version: exactVersion }) + const result = await getVulnerability({ name: dep.name, version }) if (!result) return @@ -48,7 +51,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { severity: DiagnosticSeverity.Error, code: { value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(dep.name, exactVersion)), + target: Uri.parse(npmxPackageUrl(dep.name, version)), }, } } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 5dc4c72..e397ea0 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -2,7 +2,7 @@ import type { Extractor } from '#types/extractor' import type { HoverProvider, Position, TextDocument } from 'vscode' import { getPackageInfo } from '#utils/api/package' import { npmPacakgeUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { extractVersion } from '#utils/package' +import { parseVersion } from '#utils/package' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { @@ -22,24 +22,30 @@ export class NpmxHoverProvider implements HoverProvider { if (!dep) return - const { name, version } = dep - const coercedVersion = extractVersion(version) - const md = new MarkdownString('', true) - md.isTrusted = true + const parsed = parseVersion(dep.version) + if (!parsed) + return + + const { name } = dep const pkg = await getPackageInfo(name) if (!pkg) return - const currentVersion = pkg.versionsMeta[coercedVersion] + const md = new MarkdownString('', true) + md.isTrusted = true + + const { version } = parsed + + const currentVersion = pkg.versionsMeta[version] if (currentVersion) { if (currentVersion.provenance) - md.appendMarkdown(`[$(verified) Verified provenance](${npmPacakgeUrl(name, coercedVersion)}#provenance)\n\n`) + md.appendMarkdown(`[$(verified) Verified provenance](${npmPacakgeUrl(name, version)}#provenance)\n\n`) } const footer = [ `[View on npmx](${npmxPackageUrl(name)})`, - `[View docs on npmx](${npmxDocsUrl(name, coercedVersion)})`, + `[View docs on npmx](${npmxDocsUrl(name, version)})`, ] md.appendMarkdown(`${footer.join(' | ')}\n`) diff --git a/src/utils/package.ts b/src/utils/package.ts index 7fc4e81..3d81826 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -9,17 +9,36 @@ export function encodePackageName(name: string): string { return encodeURIComponent(name) } -export function isValidPrefix(c: string) { - return c === '^' || c === '~' -} +const WORKSPACE_PREFIX = 'workspace:' +const CATALOG_PREFIX = 'catalog:' +const NPM_PREFIX = 'npm:' +const JSR_PREFIX = 'jsr:' -export function extractVersionPrefix(v: string) { - const firstChar = v[0] - const valid = isValidPrefix(firstChar) +export type VersionProtocol = 'npm' | null - return valid ? firstChar : '' -} +export function parseVersion(rawVersion: string): { prefix: '' | '^' | '~', version: string, protocol: VersionProtocol } | null { + // Skip special protocols that aren't standard npm versions + if ( + rawVersion.startsWith(WORKSPACE_PREFIX) + || rawVersion.startsWith(CATALOG_PREFIX) + || rawVersion.startsWith(JSR_PREFIX) + ) { + return null + } + + let protocol: VersionProtocol = null + let versionStr = rawVersion + + // Handle npm: protocol (e.g., npm:^1.0.0) + if (rawVersion.startsWith(NPM_PREFIX)) { + protocol = 'npm' + versionStr = rawVersion.slice(NPM_PREFIX.length) + } + + const firstChar = versionStr[0] + const hasPrefix = firstChar === '^' || firstChar === '~' + const prefix = hasPrefix ? firstChar : '' + const version = hasPrefix ? versionStr.slice(1) : versionStr -export function extractVersion(versionRange: string): string { - return versionRange.replace(/^[\^~]/, '') + return { prefix, version, protocol } } diff --git a/tests/package.test.ts b/tests/package.test.ts new file mode 100644 index 0000000..212acaf --- /dev/null +++ b/tests/package.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { encodePackageName, parseVersion } from '../src/utils/package' + +describe('encodePackageName', () => { + it('should encode regular package name', () => { + expect(encodePackageName('lodash')).toBe('lodash') + }) + + it('should encode scoped package name', () => { + expect(encodePackageName('@vue/core')).toBe('@vue%2Fcore') + }) +}) + +describe('parseVersion', () => { + it('should parse plain version', () => { + expect(parseVersion('1.0.0')).toEqual({ + prefix: '', + version: '1.0.0', + protocol: null, + }) + }) + + it('should parse version with ^ prefix', () => { + expect(parseVersion('^1.2.3')).toEqual({ + prefix: '^', + version: '1.2.3', + protocol: null, + }) + }) + + it('should parse version with ~ prefix', () => { + expect(parseVersion('~2.0.0')).toEqual({ + prefix: '~', + version: '2.0.0', + protocol: null, + }) + }) + + it('should parse npm: protocol', () => { + expect(parseVersion('npm:1.0.0')).toEqual({ + prefix: '', + version: '1.0.0', + protocol: 'npm', + }) + }) + + it('should parse npm: protocol with prefix', () => { + expect(parseVersion('npm:^1.0.0')).toEqual({ + prefix: '^', + version: '1.0.0', + protocol: 'npm', + }) + }) + + it('should return null for workspace:', () => { + expect(parseVersion('workspace:*')).toBeNull() + }) + + it('should return null for catalog:', () => { + expect(parseVersion('catalog:default')).toBeNull() + }) + + it('should return null for jsr:', () => { + expect(parseVersion('jsr:@std/fs')).toBeNull() + }) +}) From 5325ab1389bab1262893be37a62e9642eefd1788 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 5 Feb 2026 12:19:47 +0800 Subject: [PATCH 2/7] replace `version` with `semver` --- .../diagnostics/rules/deprecation.ts | 8 ++++---- .../diagnostics/rules/vulnerability.ts | 8 ++++---- src/providers/hover/npmx.ts | 8 ++++---- src/utils/package.ts | 17 +++++++++++++--- tests/package.test.ts | 20 +++++++++---------- 5 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 12bfab8..70bfe90 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -8,19 +8,19 @@ export const checkDeprecation: DiagnosticRule = (dep, pkg) => { if (!parsed) return - const { version } = parsed - const versionInfo = pkg.versionsMeta[version] + const { semver } = parsed + const versionInfo = pkg.versionsMeta[semver] if (!versionInfo?.deprecated) return return { node: dep.versionNode, - message: `${dep.name} v${version} has been deprecated: ${versionInfo.deprecated}`, + message: `${dep.name} v${semver} has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { value: 'deprecation', - target: Uri.parse(npmxPackageUrl(dep.name, version)), + target: Uri.parse(npmxPackageUrl(dep.name, semver)), }, tags: [DiagnosticTag.Deprecated], } diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index c79121d..e89f67c 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -17,12 +17,12 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { if (!parsed) return - const { version } = parsed - const versionInfo = pkg.versionsMeta[version] + const { semver } = parsed + const versionInfo = pkg.versionsMeta[semver] if (!versionInfo) return - const result = await getVulnerability({ name: dep.name, version }) + const result = await getVulnerability({ name: dep.name, version: semver }) if (!result) return @@ -51,7 +51,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { severity: DiagnosticSeverity.Error, code: { value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(dep.name, version)), + target: Uri.parse(npmxPackageUrl(dep.name, semver)), }, } } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index c7b95b1..ab18522 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -36,16 +36,16 @@ export class NpmxHoverProvider implements HoverProvider { const md = new MarkdownString('', true) md.isTrusted = true - const { version } = parsed + const { semver } = parsed - const currentVersion = pkg.versionsMeta[version] + const currentVersion = pkg.versionsMeta[semver] if (currentVersion) { if (currentVersion.provenance) - md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmPacakgeUrl(name, version)}#provenance)\n\n`) + md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmPacakgeUrl(name, semver)}#provenance)\n\n`) } const packageLink = `[$(package)${SPACER}View on npmx](${npmxPackageUrl(name)})` - const docsLink = `[$(book)${SPACER}View docs on npmx](${npmxDocsUrl(name, version)})` + const docsLink = `[$(book)${SPACER}View docs on npmx](${npmxDocsUrl(name, semver)})` md.appendMarkdown(`${packageLink} | ${docsLink}`) diff --git a/src/utils/package.ts b/src/utils/package.ts index 3d81826..b7e37ac 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -16,7 +16,18 @@ const JSR_PREFIX = 'jsr:' export type VersionProtocol = 'npm' | null -export function parseVersion(rawVersion: string): { prefix: '' | '^' | '~', version: string, protocol: VersionProtocol } | null { +export interface ParsedVersion { + protocol: VersionProtocol + prefix: '' | '^' | '~' + semver: string +} + +export function formatVersion(parsed: ParsedVersion): string { + const protocol = parsed.protocol ? `${parsed.protocol}:` : '' + return `${protocol}${parsed.prefix}${parsed.semver}` +} + +export function parseVersion(rawVersion: string): ParsedVersion | null { // Skip special protocols that aren't standard npm versions if ( rawVersion.startsWith(WORKSPACE_PREFIX) @@ -38,7 +49,7 @@ export function parseVersion(rawVersion: string): { prefix: '' | '^' | '~', vers const firstChar = versionStr[0] const hasPrefix = firstChar === '^' || firstChar === '~' const prefix = hasPrefix ? firstChar : '' - const version = hasPrefix ? versionStr.slice(1) : versionStr + const semver = hasPrefix ? versionStr.slice(1) : versionStr - return { prefix, version, protocol } + return { protocol, prefix, semver } } diff --git a/tests/package.test.ts b/tests/package.test.ts index 212acaf..3f3895e 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -14,41 +14,41 @@ describe('encodePackageName', () => { describe('parseVersion', () => { it('should parse plain version', () => { expect(parseVersion('1.0.0')).toEqual({ - prefix: '', - version: '1.0.0', protocol: null, + prefix: '', + semver: '1.0.0', }) }) it('should parse version with ^ prefix', () => { expect(parseVersion('^1.2.3')).toEqual({ - prefix: '^', - version: '1.2.3', protocol: null, + prefix: '^', + semver: '1.2.3', }) }) it('should parse version with ~ prefix', () => { expect(parseVersion('~2.0.0')).toEqual({ - prefix: '~', - version: '2.0.0', protocol: null, + prefix: '~', + semver: '2.0.0', }) }) it('should parse npm: protocol', () => { expect(parseVersion('npm:1.0.0')).toEqual({ - prefix: '', - version: '1.0.0', protocol: 'npm', + prefix: '', + semver: '1.0.0', }) }) it('should parse npm: protocol with prefix', () => { expect(parseVersion('npm:^1.0.0')).toEqual({ - prefix: '^', - version: '1.0.0', protocol: 'npm', + prefix: '^', + semver: '1.0.0', }) }) From e3b8efa22f1c785a3f1254230afb5d0f402efc2c Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 5 Feb 2026 14:01:49 +0800 Subject: [PATCH 3/7] fix: support `npm:` prefix semver --- playground/package.json | 1 + src/constants.ts | 2 +- src/providers/completion-item/version.ts | 12 +++++------- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/playground/package.json b/playground/package.json index b8d210a..0314808 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@deno/doc": "jsr:^0.189.1", + "nuxt": "npm:4.3.0", "@prismicio/client": "~7.21.0-canary.147e3f2" }, "devDependencies": { diff --git a/src/constants.ts b/src/constants.ts index 8b14e79..ddeedac 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,7 +4,7 @@ export const PNPM_WORKSPACE_BASENAME = 'pnpm-workspace.yaml' export const PACKAGE_JSON_PATTERN = `**/${PACKAGE_JSON_BASENAME}` export const PNPM_WORKSPACE_PATTERN = `**/${PNPM_WORKSPACE_BASENAME}` -export const VERSION_TRIGGER_CHARACTERS = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] +export const VERSION_TRIGGER_CHARACTERS = [':', '^', '~', '.', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24 diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 52d7953..77f85e2 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -2,7 +2,7 @@ import type { Extractor } from '#types/extractor' import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' -import { parseVersion } from '#utils/package' +import { formatVersion, parseVersion } from '#utils/package' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { @@ -36,23 +36,21 @@ export class VersionCompletionItemProvider implements Compl if (!parsed) return - const { prefix } = parsed - const items: CompletionItem[] = [] - for (const version in pkg.versionsMeta) { - const meta = pkg.versionsMeta[version] + for (const semver in pkg.versionsMeta) { + const meta = pkg.versionsMeta[semver] if (config.completion.version === 'provenance-only' && !meta.provenance) continue - const text = `${prefix}${version}` + const text = formatVersion({ ...parsed, semver }) const item = new CompletionItem(text, CompletionItemKind.Value) item.range = this.extractor.getNodeRange(document, versionNode) item.insertText = text - const tag = pkg.versionToTag.get(version) + const tag = pkg.versionToTag.get(semver) if (tag) item.detail = tag From 76183ab7077dc02ed89627f3008618acaec4be7c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 06:02:52 +0000 Subject: [PATCH 4/7] [autofix.ci] apply automated fixes --- playground/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/package.json b/playground/package.json index 0314808..d633bb6 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,8 +1,8 @@ { "dependencies": { "@deno/doc": "jsr:^0.189.1", - "nuxt": "npm:4.3.0", - "@prismicio/client": "~7.21.0-canary.147e3f2" + "@prismicio/client": "~7.21.0-canary.147e3f2", + "nuxt": "npm:4.3.0" }, "devDependencies": { "array-includes": "", From 6df3c07b6da618f6936ec136198a82fff6ef2457 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 5 Feb 2026 14:08:53 +0800 Subject: [PATCH 5/7] perf: early return for the unsupported protocol --- src/providers/completion-item/version.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 77f85e2..19cff1f 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -28,14 +28,14 @@ export class VersionCompletionItemProvider implements Compl version, } = info - const pkg = await getPackageInfo(name) - if (!pkg) - return - const parsed = parseVersion(version) if (!parsed) return + const pkg = await getPackageInfo(name) + if (!pkg) + return + const items: CompletionItem[] = [] for (const semver in pkg.versionsMeta) { From 49f5d5856805417747c10c45675487be98a3c61f Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 5 Feb 2026 14:33:49 +0800 Subject: [PATCH 6/7] fix: include URL prefixes in version parsing logic --- src/utils/package.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/utils/package.ts b/src/utils/package.ts index b7e37ac..fc9e1db 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -13,6 +13,7 @@ const WORKSPACE_PREFIX = 'workspace:' const CATALOG_PREFIX = 'catalog:' const NPM_PREFIX = 'npm:' const JSR_PREFIX = 'jsr:' +const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] export type VersionProtocol = 'npm' | null @@ -30,9 +31,12 @@ export function formatVersion(parsed: ParsedVersion): string { export function parseVersion(rawVersion: string): ParsedVersion | null { // Skip special protocols that aren't standard npm versions if ( - rawVersion.startsWith(WORKSPACE_PREFIX) - || rawVersion.startsWith(CATALOG_PREFIX) - || rawVersion.startsWith(JSR_PREFIX) + [ + WORKSPACE_PREFIX, + CATALOG_PREFIX, + JSR_PREFIX, + ...URL_PREFIXES, + ].some((p) => rawVersion.startsWith(p)) ) { return null } From b482ac0e95c3ad6aaa7ee745ec06d224a59f4eda Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 5 Feb 2026 14:37:56 +0800 Subject: [PATCH 7/7] hard code slice length --- src/utils/package.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/package.ts b/src/utils/package.ts index fc9e1db..8043e05 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -47,7 +47,7 @@ export function parseVersion(rawVersion: string): ParsedVersion | null { // Handle npm: protocol (e.g., npm:^1.0.0) if (rawVersion.startsWith(NPM_PREFIX)) { protocol = 'npm' - versionStr = rawVersion.slice(NPM_PREFIX.length) + versionStr = rawVersion.slice(4 /* NPM_PREFIX.length */) } const firstChar = versionStr[0]