diff --git a/playground/package.json b/playground/package.json index b8d210a..d633bb6 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,7 +1,8 @@ { "dependencies": { "@deno/doc": "jsr:^0.189.1", - "@prismicio/client": "~7.21.0-canary.147e3f2" + "@prismicio/client": "~7.21.0-canary.147e3f2", + "nuxt": "npm:4.3.0" }, "devDependencies": { "array-includes": "", 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 133a5d5..19cff1f 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 { formatVersion, parseVersion } from '#utils/package' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { @@ -28,27 +28,29 @@ export class VersionCompletionItemProvider implements Compl version, } = info + const parsed = parseVersion(version) + if (!parsed) + return + const pkg = await getPackageInfo(name) if (!pkg) return - const prefix = extractVersionPrefix(version) - 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 diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 6b52148..70bfe90 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, DiagnosticTag, 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 { semver } = parsed + const versionInfo = pkg.versionsMeta[semver] if (!versionInfo?.deprecated) return return { node: dep.versionNode, - message: `${dep.name} v${exactVersion} 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, exactVersion)), + 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 89d40cc..e89f67c 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 { semver } = parsed + const versionInfo = pkg.versionsMeta[semver] if (!versionInfo) return - const result = await getVulnerability({ name: dep.name, version: exactVersion }) + const result = await getVulnerability({ name: dep.name, version: semver }) 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, semver)), }, } } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 025e98b..ab18522 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -3,7 +3,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' 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 { @@ -23,23 +23,29 @@ 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 { semver } = parsed + + const currentVersion = pkg.versionsMeta[semver] if (currentVersion) { if (currentVersion.provenance) - md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmPacakgeUrl(name, coercedVersion)}#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, coercedVersion)})` + 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 7fc4e81..8043e05 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -9,17 +9,51 @@ 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:' +const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] + +export type VersionProtocol = 'npm' | null -export function extractVersionPrefix(v: string) { - const firstChar = v[0] - const valid = isValidPrefix(firstChar) +export interface ParsedVersion { + protocol: VersionProtocol + prefix: '' | '^' | '~' + semver: string +} - return valid ? firstChar : '' +export function formatVersion(parsed: ParsedVersion): string { + const protocol = parsed.protocol ? `${parsed.protocol}:` : '' + return `${protocol}${parsed.prefix}${parsed.semver}` } -export function extractVersion(versionRange: string): string { - return versionRange.replace(/^[\^~]/, '') +export function parseVersion(rawVersion: string): ParsedVersion | null { + // Skip special protocols that aren't standard npm versions + if ( + [ + WORKSPACE_PREFIX, + CATALOG_PREFIX, + JSR_PREFIX, + ...URL_PREFIXES, + ].some((p) => rawVersion.startsWith(p)) + ) { + 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(4 /* NPM_PREFIX.length */) + } + + const firstChar = versionStr[0] + const hasPrefix = firstChar === '^' || firstChar === '~' + const prefix = hasPrefix ? firstChar : '' + const semver = hasPrefix ? versionStr.slice(1) : versionStr + + return { protocol, prefix, semver } } diff --git a/tests/package.test.ts b/tests/package.test.ts new file mode 100644 index 0000000..3f3895e --- /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({ + protocol: null, + prefix: '', + semver: '1.0.0', + }) + }) + + it('should parse version with ^ prefix', () => { + expect(parseVersion('^1.2.3')).toEqual({ + protocol: null, + prefix: '^', + semver: '1.2.3', + }) + }) + + it('should parse version with ~ prefix', () => { + expect(parseVersion('~2.0.0')).toEqual({ + protocol: null, + prefix: '~', + semver: '2.0.0', + }) + }) + + it('should parse npm: protocol', () => { + expect(parseVersion('npm:1.0.0')).toEqual({ + protocol: 'npm', + prefix: '', + semver: '1.0.0', + }) + }) + + it('should parse npm: protocol with prefix', () => { + expect(parseVersion('npm:^1.0.0')).toEqual({ + protocol: 'npm', + prefix: '^', + semver: '1.0.0', + }) + }) + + 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() + }) +})