diff --git a/README.md b/README.md index 9456e4e..bd1889d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ | `npmx.hover.enabled` | Enable hover information for packages | `boolean` | `true` | | `npmx.completion.version` | Version completion behavior | `string` | `"provenance-only"` | | `npmx.completion.excludePrerelease` | Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions | `boolean` | `true` | +| `npmx.diagnostics.upgrade` | Show hints when a newer version of a package is available | `boolean` | `true` | | `npmx.diagnostics.deprecation` | Show warnings for deprecated packages | `boolean` | `true` | | `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` | | `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | diff --git a/package.json b/package.json index 6e50cfc..16cd5b9 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,11 @@ "default": true, "description": "Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions" }, + "npmx.diagnostics.upgrade": { + "type": "boolean", + "default": true, + "description": "Show hints when a newer version of a package is available" + }, "npmx.diagnostics.deprecation": { "type": "boolean", "default": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 297ce75..8efc04f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2667,8 +2667,8 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -3968,7 +3968,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -4143,7 +4143,7 @@ snapshots: parse-semver: 1.1.1 read: 1.0.7 secretlint: 10.2.2 - semver: 7.7.3 + semver: 7.7.4 tmp: 0.2.5 typed-rest-client: 1.8.11 url-join: 4.0.1 @@ -4586,12 +4586,12 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-compat-utils@0.6.5(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-config-flat-gitignore@2.1.0(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -4630,7 +4630,7 @@ snapshots: dependencies: empathic: 2.0.0 module-replacements: 2.11.0 - semver: 7.7.3 + semver: 7.7.4 eslint-plugin-es-x@7.8.0(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -4657,7 +4657,7 @@ snapshots: html-entities: 2.6.0 object-deep-merge: 2.0.0 parse-imports-exports: 0.2.4 - semver: 7.7.3 + semver: 7.7.4 spdx-expression-parse: 4.0.0 to-valid-identifier: 1.0.0 transitivePeerDependencies: @@ -4688,7 +4688,7 @@ snapshots: globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 - semver: 7.7.3 + semver: 7.7.4 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -4755,7 +4755,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.0 - semver: 7.7.3 + semver: 7.7.4 strip-indent: 4.1.1 eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): @@ -4771,7 +4771,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.3 + semver: 7.7.4 vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: @@ -5202,7 +5202,7 @@ snapshots: acorn: 8.15.0 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - semver: 7.7.3 + semver: 7.7.4 jsonc-parser@3.3.1: {} @@ -5223,7 +5223,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 jwa@1.4.2: dependencies: @@ -5693,7 +5693,7 @@ snapshots: node-abi@3.78.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 optional: true node-addon-api@4.3.0: @@ -5709,7 +5709,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 nth-check@2.1.1: @@ -6051,7 +6051,7 @@ snapshots: semver@5.7.2: {} - semver@7.7.3: {} + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -6274,7 +6274,7 @@ snapshots: picomatch: 4.0.3 rolldown: 1.0.0-rc.3 rolldown-plugin-dts: 0.22.1(rolldown@1.0.0-rc.3)(typescript@5.9.3) - semver: 7.7.3 + semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 @@ -6444,7 +6444,7 @@ snapshots: eslint-visitor-keys: 4.2.1 espree: 10.4.0 esquery: 1.7.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color diff --git a/src/constants.ts b/src/constants.ts index defcc10..345a2be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,3 +13,5 @@ export const NPMX_DEV = 'https://npmx.dev' export const NPMX_DEV_API = `${NPMX_DEV}/api` export const SPACER = ' ' + +export const UPGRADE_MESSAGE_PREFIX = 'New version available: ' diff --git a/src/index.ts b/src/index.ts index a6283e6..048a7d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,13 @@ import { VERSION_TRIGGER_CHARACTERS, } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { CodeActionKind, Disposable, languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' +import { UpgradeProvider } from './providers/code-actions/upgrade' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -61,6 +62,20 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + watchEffect((onCleanup) => { + if (!config.diagnostics.upgrade) + return + + const provider = new UpgradeProvider() + const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } + const disposable = Disposable.from( + languages.registerCodeActionsProvider({ pattern: PACKAGE_JSON_PATTERN }, provider, options), + languages.registerCodeActionsProvider({ pattern: PNPM_WORKSPACE_PATTERN }, provider, options), + ) + + onCleanup(() => disposable.dispose()) + }) + registerDiagnosticCollection({ [PACKAGE_JSON_BASENAME]: packageJsonExtractor, [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, diff --git a/src/providers/code-actions/upgrade.ts b/src/providers/code-actions/upgrade.ts new file mode 100644 index 0000000..daeae8a --- /dev/null +++ b/src/providers/code-actions/upgrade.ts @@ -0,0 +1,19 @@ +import type { CodeActionContext, CodeActionProvider, Command, ProviderResult, Range, Selection, TextDocument } from 'vscode' +import { UPGRADE_MESSAGE_PREFIX } from '#constants' +import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' + +export class UpgradeProvider implements CodeActionProvider { + provideCodeActions(document: TextDocument, _range: Range | Selection, context: CodeActionContext): ProviderResult<(CodeAction | Command)[]> { + return context.diagnostics.flatMap((d) => { + if (!d.message.startsWith(UPGRADE_MESSAGE_PREFIX)) + return [] + + const target = d.message.slice(UPGRADE_MESSAGE_PREFIX.length) + const fix = new CodeAction(`Update to ${target}`, CodeActionKind.QuickFix) + fix.edit = new WorkspaceEdit() + fix.edit.replace(document.uri, d.range, `${target}`) + fix.diagnostics = [d] + return [fix] + }) + } +} diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 3db7d51..c10a3a7 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -3,7 +3,7 @@ import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' -import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' +import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 457270e..2b146fd 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -11,6 +11,7 @@ import { languages } from 'vscode' import { displayName } from '../../generated-meta' import { checkDeprecation } from './rules/deprecation' import { checkReplacement } from './rules/replacement' +import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' export interface NodeDiagnosticInfo extends Omit { @@ -20,6 +21,8 @@ export type DiagnosticRule = (dep: DependencyInfo, pkg: PackageInfo) => Awaitabl const enabledRules = computed(() => { const rules: DiagnosticRule[] = [] + if (config.diagnostics.upgrade) + rules.push(checkUpgrade) if (config.diagnostics.deprecation) rules.push(checkDeprecation) if (config.diagnostics.replacement) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index a0b6a6a..4c7d1a3 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -1,6 +1,6 @@ import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, parseVersion } from '#utils/package' +import { isSupportedProtocol, parseVersion } from '#utils/version' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' export const checkDeprecation: DiagnosticRule = (dep, pkg) => { diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts new file mode 100644 index 0000000..28b7d5d --- /dev/null +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -0,0 +1,42 @@ +import type { DependencyInfo } from '#types/extractor' +import type { ParsedVersion } from '#utils/version' +import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import { UPGRADE_MESSAGE_PREFIX } from '#constants' +import { formatVersion, getPrereleaseId, isSupportedProtocol, lt, parseVersion } from '#utils/version' +import { DiagnosticSeverity } from 'vscode' + +function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upgradeVersion: string): NodeDiagnosticInfo { + const target = formatVersion({ ...parsed, semver: upgradeVersion }) + return { + node: dep.versionNode, + severity: DiagnosticSeverity.Hint, + message: `${UPGRADE_MESSAGE_PREFIX}${target}`, + } +} + +export const checkUpgrade: DiagnosticRule = (dep, pkg) => { + const parsed = parseVersion(dep.version) + if (!parsed || !isSupportedProtocol(parsed.protocol)) + return + + const { semver } = parsed + const latest = pkg.distTags.latest + + if (latest && lt(semver, latest)) + return createUpgradeDiagnostic(dep, parsed, latest) + + const currentPreId = getPrereleaseId(semver) + if (!currentPreId) + return + + for (const [tag, tagVersion] of Object.entries(pkg.distTags)) { + if (tag === 'latest') + continue + if (getPrereleaseId(tagVersion) !== currentPreId) + continue + if (!lt(semver, tagVersion)) + continue + + return createUpgradeDiagnostic(dep, parsed, tagVersion) + } +} diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 01cbc08..b42a4bc 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 { isSupportedProtocol, parseVersion } from '#utils/package' +import { isSupportedProtocol, parseVersion } from '#utils/version' import { DiagnosticSeverity, Uri } from 'vscode' const DIAGNOSTIC_MAPPING: Record, DiagnosticSeverity> = { diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 3d5c757..78f028c 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 { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, parseVersion } from '#utils/package' +import { isSupportedProtocol, parseVersion } from '#utils/version' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { diff --git a/src/utils/package.ts b/src/utils/package.ts index 487798e..b5429bd 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -8,52 +8,3 @@ export function encodePackageName(name: string): string { } return encodeURIComponent(name) } - -export type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null - -const KNOWN_PROTOCOLS = new Set(['workspace', 'catalog', 'npm', 'jsr']) -const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] -const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) - -export interface ParsedVersion { - protocol: VersionProtocol - prefix: '' | '^' | '~' - semver: string -} - -export function isSupportedProtocol(protocol: VersionProtocol): boolean { - return !UNSUPPORTED_PROTOCOLS.has(protocol) -} - -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 { - rawVersion = rawVersion.trim() - // Skip URL-based versions - if (URL_PREFIXES.some((p) => rawVersion.startsWith(p))) - return null - - let protocol: VersionProtocol = null - let versionStr = rawVersion - - // Parse protocol if present (e.g., npm:^1.0.0 -> protocol: 'npm') - const colonIndex = rawVersion.indexOf(':') - if (colonIndex !== -1) { - protocol = rawVersion.slice(0, colonIndex) as VersionProtocol - - if (!KNOWN_PROTOCOLS.has(protocol)) - return null - - versionStr = rawVersion.slice(colonIndex + 1) - } - - 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/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..6b0a5b0 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,103 @@ +type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null + +const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] +const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) +const KNOWN_PROTOCOLS = new Set([...UNSUPPORTED_PROTOCOLS, 'npm']) + +export interface ParsedVersion { + protocol: VersionProtocol + prefix: '' | '^' | '~' + semver: string +} + +export function isSupportedProtocol(protocol: VersionProtocol): boolean { + return !protocol || !UNSUPPORTED_PROTOCOLS.has(protocol) +} + +export function formatVersion(parsed: ParsedVersion): string { + const protocol = parsed.protocol ? `${parsed.protocol}:` : '' + return `${protocol}${parsed.prefix}${parsed.semver}` +} + +function isKnownProtocol(protocol: string): protocol is NonNullable { + return KNOWN_PROTOCOLS.has(protocol) +} + +export function parseVersion(rawVersion: string): ParsedVersion | null { + rawVersion = rawVersion.trim() + if (URL_PREFIXES.some((p) => rawVersion.startsWith(p))) + return null + + let protocol: string | null = null + let versionStr = rawVersion + + const colonIndex = rawVersion.indexOf(':') + if (colonIndex !== -1) { + protocol = rawVersion.slice(0, colonIndex) + + if (!isKnownProtocol(protocol)) + return null + + versionStr = rawVersion.slice(colonIndex + 1) + } + + const firstChar = versionStr[0] + const hasPrefix = firstChar === '^' || firstChar === '~' + const prefix = hasPrefix ? firstChar : '' + const semver = hasPrefix ? versionStr.slice(1) : versionStr + + return { protocol, prefix, semver } +} + +export function getPrereleaseId(version: string): string | null { + const idx = version.indexOf('-') + if (idx === -1) + return null + const pre = version.slice(idx + 1).split('.')[0] + return pre || null +} + +/** + * Compare two pre-release strings part by part following SemVer precedence rules. + * + * Numeric parts are compared as numbers, string parts are compared lexicographically. + * A version with fewer parts is less than one with more parts when all preceding parts are equal. + */ +function comparePrereleasePrecedence(a: string, b: string): number { + const partsA = a.split('.') + const partsB = b.split('.') + + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + if (i >= partsA.length) + return -1 + if (i >= partsB.length) + return 1 + + const numA = Number(partsA[i]) + const numB = Number(partsB[i]) + if (!Number.isNaN(numA) && !Number.isNaN(numB)) { + return numA - numB + } else if (partsA[i] !== partsB[i]) { + return partsA[i] < partsB[i] ? -1 : 1 + } + } + + return 0 +} + +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) + for (let i = 0; i < 3; i++) { + const diff = (partsA[i] || 0) - (partsB[i] || 0) + if (diff !== 0) + return diff < 0 + } + if (preA && !preB) + return true + if (!preA || !preB) + return false + return comparePrereleasePrecedence(preA, preB) < 0 +} diff --git a/tests/package.test.ts b/tests/package.test.ts index 7eb5ecd..e3ab59d 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { encodePackageName, parseVersion } from '../src/utils/package' +import { encodePackageName } from '../src/utils/package' describe('encodePackageName', () => { it('should encode regular package name', () => { @@ -10,75 +10,3 @@ describe('encodePackageName', () => { 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 parse workspace: protocol', () => { - expect(parseVersion('workspace:*')).toEqual({ - protocol: 'workspace', - prefix: '', - semver: '*', - }) - }) - - it('should parse catalog: protocol', () => { - expect(parseVersion('catalog:default')).toEqual({ - protocol: 'catalog', - prefix: '', - semver: 'default', - }) - }) - - it('should parse jsr: protocol', () => { - expect(parseVersion('jsr:^1.1.4')).toEqual({ - protocol: 'jsr', - prefix: '^', - semver: '1.1.4', - }) - }) - - it('should return null for URL-based versions', () => { - expect(parseVersion('https://github.com/user/repo')).toBeNull() - expect(parseVersion('git://github.com/user/repo')).toBeNull() - expect(parseVersion('git+https://github.com/user/repo')).toBeNull() - }) -}) diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..4f26a5c --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { getPrereleaseId, lt, parseVersion } from '../src/utils/version' + +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 parse workspace: protocol', () => { + expect(parseVersion('workspace:*')).toEqual({ + protocol: 'workspace', + prefix: '', + semver: '*', + }) + }) + + it('should parse catalog: protocol', () => { + expect(parseVersion('catalog:default')).toEqual({ + protocol: 'catalog', + prefix: '', + semver: 'default', + }) + }) + + it('should parse jsr: protocol', () => { + expect(parseVersion('jsr:^1.1.4')).toEqual({ + protocol: 'jsr', + prefix: '^', + semver: '1.1.4', + }) + }) + + it('should return null for URL-based versions', () => { + expect(parseVersion('https://github.com/user/repo')).toBeNull() + expect(parseVersion('git://github.com/user/repo')).toBeNull() + expect(parseVersion('git+https://github.com/user/repo')).toBeNull() + }) +}) + +describe('getPrereleaseId', () => { + it('should return null for stable versions', () => { + expect(getPrereleaseId('1.0.0')).toBeNull() + }) + + it('should extract identifier', () => { + expect(getPrereleaseId('2.0.0-beta.1')).toBe('beta') + }) + + it('should handle prerelease without dots', () => { + expect(getPrereleaseId('1.0.0-canary')).toBe('canary') + }) +}) + +describe('lt', () => { + it('should compare major versions', () => { + expect(lt('1.0.0', '2.0.0')).toBe(true) + expect(lt('2.0.0', '1.0.0')).toBe(false) + }) + + it('should compare minor versions', () => { + expect(lt('1.0.0', '1.1.0')).toBe(true) + expect(lt('1.1.0', '1.0.0')).toBe(false) + }) + + it('should compare patch versions', () => { + expect(lt('1.0.0', '1.0.1')).toBe(true) + expect(lt('1.0.1', '1.0.0')).toBe(false) + }) + + it('should return false for equal versions', () => { + expect(lt('1.0.0', '1.0.0')).toBe(false) + }) + + it('should treat prerelease as less than release', () => { + expect(lt('1.0.0-beta.1', '1.0.0')).toBe(true) + expect(lt('1.0.0', '1.0.0-beta.1')).toBe(false) + }) + + it('should compare prerelease versions numerically', () => { + expect(lt('1.0.0-beta.1', '1.0.0-beta.2')).toBe(true) + expect(lt('1.0.0-beta.2', '1.0.0-beta.1')).toBe(false) + }) + + it('should compare different prerelease identifiers', () => { + expect(lt('1.0.0-alpha.1', '1.0.0-beta.1')).toBe(true) + expect(lt('1.0.0-beta.1', '1.0.0-alpha.1')).toBe(false) + }) + + it('should handle prerelease with fewer segments', () => { + 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) + }) +})