Skip to content

Commit

Permalink
chore: use winget-specific version compare algorithm
Browse files Browse the repository at this point in the history
  • Loading branch information
anatawa12 committed Jul 5, 2024
1 parent d9963b5 commit 6b70732
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 1 deletion.
144 changes: 144 additions & 0 deletions services/winget/version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Comparing versions with winget's version comparator.
*
* See https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp for original implementation.
*
* @module
*/

/**
* Compares two strings representing version numbers lexicographically and returns an integer value.
*
* @param {string} v1 - The first version to compare
* @param {string} v2 - The second version to compare
* @returns {number} -1 if v1 is smaller than v2, 1 if v1 is larger than v2, 0 if v1 and v2 are equal
* @example
* compareVersion('1.2.3', '1.2.4') // returns -1 because numeric part of first version is smaller than the numeric part of second version.
*/
function compareVersion(v1, v2) {
// https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L109-L173
const v1Trimmed = trimPrefix(v1)
const v2Trimmed = trimPrefix(v2)

const parts1 = v1Trimmed.split('.')
const parts2 = v2Trimmed.split('.')

for (let i = 0; i < Math.min(parts1.length, parts2.length); i++) {
const part1 = parts1[i]
const part2 = parts2[i]

const compare = compareVersionPart(part1, part2)
if (compare !== 0) {
return compare
}
}

if (parts1.length === parts2.length) {
return 0
}

// ignore .0s at the end
if (parts1.length > parts2.length) {
for (let i = parts2.length; i < parts1.length; i++) {
if (parts1[i].trim() !== '0') {
return 1
}
}
} else if (parts1.length < parts2.length) {
for (let i = parts1.length; i < parts2.length; i++) {
if (parts2[i].trim() !== '0') {
return -1
}
}
}

return 0
}

/**
* Removes all leading non-digit characters from a version number string
* if there is a digit before the split character, or no split characters exist.
*
* @param {string} version The version number string to trim
* @returns {string} The version number string with all leading non-digit characters removed
*/
function trimPrefix(version) {
// https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L66
// If there is a digit before the split character, or no split characters exist, trim off all leading non-digit characters

const digitPos = version.match(/(\d.*)/)
const splitPos = version.match(/\./)
if (digitPos && (splitPos == null || digitPos.index < splitPos.index)) {
// there is digit before the split character so strip off all leading non-digit characters
return version.slice(digitPos.index)
}
return version
}

/**
* Compares two strings representing version number parts lexicographically and returns an integer value.
*
* @param {string} part1 - The first version part to compare
* @param {string} part2 - The second version part to compare
* @returns {number} -1 if part1 is smaller than part2, 1 if part1 is larger than part2, 0 if part1 and part2 are equal
* @example
* compareVersionPart('3', '4') // returns -1 because numeric part of first part is smaller than the numeric part of second part.
*/
function compareVersionPart(part1, part2) {
// https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L324-L352
const [, numericString1, other1] = part1.trim().match(/^(\d*)(.*)$/)
const [, numericString2, other2] = part2.trim().match(/^(\d*)(.*)$/)
const numeric1 = parseInt(numericString1 || '0', 10)
const numeric2 = parseInt(numericString2 || '0', 10)

if (numeric1 < numeric2) {
return -1
} else if (numeric1 > numeric2) {
return 1
}
// numeric1 === numeric2

const otherFolded1 = (other1 ?? '').toLowerCase()
const otherFolded2 = (other2 ?? '').toLowerCase()

if (otherFolded1.length !== 0 && otherFolded2.length === 0) {
return -1
} else if (otherFolded1.length === 0 && otherFolded2.length !== 0) {
return 1
}

if (otherFolded1 < otherFolded2) {
return -1
} else if (otherFolded1 > otherFolded2) {
return 1
}

return 0
}

/**
* Finds the largest version number lexicographically from an array of strings representing version numbers and returns it as a string.
*
* @param {string[]} versions - The array of version numbers to compare
* @returns {string|undefined} The largest version number as a string, or undefined if the array is empty
* @example
* latest(['1.2.3', '1.2.4', '1.3', '2.0']) // returns '2.0' because it is the largest version number and pre-release versions are excluded.
* latest(['1.2.3', '1.2.4', '1.3', '2.0']) // returns '2.0' because pre-release versions are included but none of them are present in the array.
* latest(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta']) // returns '1.2.4' because pre-release versions are excluded and it is the largest version number among the remaining ones.
*/
function latest(versions) {
const len = versions.length
if (len === 0) {
return
}

let version = versions[0]
for (let i = 1; i < len; i++) {
if (compareVersion(version, versions[i]) < 0) {
version = versions[i]
}
}
return version
}

export { latest, compareVersion }
28 changes: 28 additions & 0 deletions services/winget/version.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test, given } from 'sazerac'
import { compareVersion } from './version.js'

describe('Winget Version helpers', function () {
test(compareVersion, () => {
given('1', '2').expect(-1)
given('1.0.0', '2.0.0').expect(-1)
given('0.0.1', '0.0.2').expect(-1)
given('0.0.1-alpha', '0.0.2-alpha').expect(-1)
given('0.0.1-beta', '0.0.2-alpha').expect(-1)
given('0.0.1-beta', '0.0.2-alpha').expect(-1)
given('13.9.8', '14.1').expect(-1)

given('1.0', '1.0.0').expect(0)

// Ensure whitespace doesn't affect equality
given('1.0', '1.0 ').expect(0)
given('1.0', '1. 0').expect(0)

// Ensure versions with preambles are sorted correctly
given('1.0', 'Version 1.0').expect(0)
given('foo1', 'bar1').expect(0)
given('v0.0.1', '0.0.2').expect(-1)
given('v0.0.1', 'v0.0.2').expect(-1)
given('1.a2', '1.b1').expect(-1)
given('alpha', 'beta').expect(-1)
})
})
3 changes: 2 additions & 1 deletion services/winget/winget-version.service.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Joi from 'joi'
import gql from 'graphql-tag'
import { latest, renderVersionBadge } from '../version.js'
import { renderVersionBadge } from '../version.js'
import { InvalidParameter, pathParam } from '../index.js'
import { GithubAuthV4Service } from '../github/github-auth-service.js'
import { transformErrors } from '../github/github-helpers.js'
import { latest } from './version.js'

const schema = Joi.object({
data: Joi.object({
Expand Down

0 comments on commit 6b70732

Please sign in to comment.