diff --git a/src/app.exec.ts b/src/app.exec.ts index 2a9b85aa..d91d72be 100644 --- a/src/app.exec.ts +++ b/src/app.exec.ts @@ -246,7 +246,7 @@ export async function which(arg0: string) { rx = new RegExp(`^${foo}$`) match = arg0.match(rx) if (match) { - const constraint = new semver.Range(match[1]) + const constraint = new semver.Range(`~${match[1]}`) found = {...entry, constraint} } } diff --git a/src/hooks/useCellar.ts b/src/hooks/useCellar.ts index 19b8e423..2d82d9d8 100644 --- a/src/hooks/useCellar.ts +++ b/src/hooks/useCellar.ts @@ -4,6 +4,7 @@ import SemVer from "semver" import Path from "path" //ALERT!! do not usePantry() or you can softlock in usePantry.git.ts import { usePrefix } from "hooks" +import useFlags from "./useFlags.ts" export default function useCellar() { @@ -28,6 +29,7 @@ const keg = (pkg: Package) => shelf(pkg.project).join(`v${pkg.version}`) /// returns a project’s installations (sorted by version) async function ls(project: string) { const d = shelf(project) + const { verbose } = useFlags() if (!d.isDirectory()) return [] @@ -35,15 +37,18 @@ async function ls(project: string) { for await (const [path, {name, isDirectory}] of d.ls()) { try { if (!isDirectory) continue - if (!name.startsWith("v")) continue - const version = new SemVer(name.slice(1)) + if (!name.startsWith("v") || name == 'var') continue + const version = new SemVer(name) if (await vacant(path)) continue rv.push({path, pkg: {project, version}}) } catch { // not console.warn as we allow other dirs as a design choice - console.verbose(`warn: invalid version: ${name}`) + if (verbose) { + console.warn(`warn: invalid version: ${name}`) + } } } + return rv.sort((a, b) => pkgutils.compare(a.pkg, b.pkg)) } @@ -56,7 +61,7 @@ async function resolve(pkg: Package | PackageRequirement | Path | Installation) const prefix = usePrefix() if (pkg instanceof Path) { const path = pkg - const version = new SemVer(path.basename().slice(1)) + const version = new SemVer(path.basename()) const project = path.parent().relative({ to: prefix }) return { path, pkg: { project, version } diff --git a/src/prefab/link.ts b/src/prefab/link.ts index cb70212b..ff813287 100644 --- a/src/prefab/link.ts +++ b/src/prefab/link.ts @@ -2,6 +2,7 @@ import { Package, Installation } from "types" import { useCellar } from "hooks" import Path from "path" import SemVer, * as semver from "semver" +import { panic } from "../utils/safe-utils.ts" export default async function link(pkg: Package | Installation) { const installation = await useCellar().resolve(pkg) @@ -12,19 +13,24 @@ export default async function link(pkg: Package | Installation) { .map(({pkg: {version}, path}) => [version, path] as [SemVer, Path]) .sort(([a],[b]) => a.compare(b)) + if (versions.length <= 0) { + console.error(pkg, installation) + throw new Error(`no versions`) + } + const shelf = installation.path.parent() const newest = versions.slice(-1)[0] const vMm = `${pkg.version.major}.${pkg.version.minor}` - const minorRange = new semver.Range(vMm) - const mostMinor = versions.filter(v => minorRange.satisfies(v[0])).at(-1)! + const minorRange = new semver.Range(`^${vMm}`) + const mostMinor = versions.filter(v => minorRange.satisfies(v[0])).at(-1) ?? panic() if (mostMinor[0].neq(pkg.version)) return // ^^ if we’re not the most minor we definitely not the most major await makeSymlink(`v${vMm}`) - const majorRange = new semver.Range(pkg.version.major.toString()) - const mostMajor = versions.filter(v => majorRange.satisfies(v[0])).at(-1)! + const majorRange = new semver.Range(`^${pkg.version.major.toString()}`) + const mostMajor = versions.filter(v => majorRange.satisfies(v[0])).at(-1) ?? panic() if (mostMajor[0].neq(pkg.version)) return // ^^ if we’re not the most major we definitely aren’t the newest diff --git a/src/utils/error.ts b/src/utils/error.ts index 5e3f06cc..34079414 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -133,9 +133,7 @@ export default class TeaError extends Error { export class UsageError extends Error {} -export function panic(message?: string): never { - throw new Error(message) -} +export { panic } from "./safe-utils.ts" export const wrap = , U>(fn: (...args: T) => U, id: ID) => { return (...args: T): U => { diff --git a/src/utils/hacks.ts b/src/utils/hacks.ts index 26906fce..95ff4e29 100644 --- a/src/utils/hacks.ts +++ b/src/utils/hacks.ts @@ -28,7 +28,7 @@ export function validatePackageRequirement(input: PlainObject): PackageRequireme if (constraint === undefined) { constraint = '*' } else if (isNumber(constraint)) { - constraint = `${constraint}` + constraint = `^${constraint}` } if (!isString(constraint)) { throw new Error(`invalid constraint: ${constraint}`) diff --git a/src/utils/index.ts b/src/utils/index.ts index 8cb17f2e..4ea2843b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -74,9 +74,7 @@ String.prototype.chuzzle = function() { return this.trim() || undefined } -export function chuzzle(input: number) { - return Number.isNaN(input) ? undefined : input -} +export { chuzzle } from "./safe-utils.ts" Set.prototype.insert = function(t: T) { if (this.has(t)) { diff --git a/src/utils/safe-utils.ts b/src/utils/safe-utils.ts new file mode 100644 index 00000000..548b1ac1 --- /dev/null +++ b/src/utils/safe-utils.ts @@ -0,0 +1,17 @@ +// utils safe enough “pure” stuff (eg. semver.ts, Path.ts) + +export function chuzzle(input: number) { + return Number.isNaN(input) ? undefined : input +} + +export function panic(message?: string): never { + throw new Error(message) +} + +export function flatmap(t: T | undefined | null, body: (t: T) => S | undefined, opts?: {rescue?: boolean}): NonNullable | undefined { + try { + if (t) return body(t) ?? undefined + } catch (err) { + if (!opts?.rescue) throw err + } +} diff --git a/src/utils/semver.ts b/src/utils/semver.ts index 17671a57..aba2897e 100644 --- a/src/utils/semver.ts +++ b/src/utils/semver.ts @@ -1,4 +1,4 @@ -// deno-lint-ignore-file no-cond-assign +import { isArray, isString } from "is_what" /** * we have our own implementation because open source is full of weird @@ -8,46 +8,65 @@ * it also allows us to implement semver_intersection without hating our lives */ export default class SemVer { + readonly components: number[] + major: number minor: number patch: number //FIXME - prerelease: string[] = [] - build: string[] = [] + readonly prerelease: string[] = [] + readonly build: string[] = [] - raw: string + readonly raw: string + readonly pretty?: string - constructor(input: string | [number,number,number] | number | Range) { + constructor(input: string | number[] | Range | SemVer) { if (typeof input == 'string') { - const match = input.match(/(\d+)\.(\d+)\.(\d+)/) - if (!match) throw new Error(`invalid semver: ${input}`) - this.major = parseInt(match[1])! - this.minor = parseInt(match[2])! - this.patch = parseInt(match[3])! + if (input.startsWith('v')) input = input.slice(1) + const parts = input.split('.') + let pretty_is_raw = false + this.components = parts.flatMap((x, index) => { + const match = x.match(/^(\d+)([a-z])$/) + if (match) { + if (index != parts.length - 1) throw new Error(`invalid version: ${input}`) + const n = parseInt(match[1]) + if (isNaN(n)) throw new Error(`invalid version: ${input}`) + pretty_is_raw = true + return [n, char_to_num(match[2])] + } else { + const n = parseInt(x) + if (isNaN(n)) throw new Error(`invalid version: ${input}`) + return [n] + } + }) this.raw = input - } else if (typeof input == 'number') { - this.major = input - this.minor = 0 - this.patch = 0 - this.raw = input.toString() - } else if (input instanceof Range) { - const v = input.single() + if (pretty_is_raw) this.pretty = input + } else if (input instanceof Range || input instanceof SemVer) { + const v = input instanceof Range ? input.single() : input if (!v) throw new Error(`range represents more than a single version: ${input}`) - this.major = v.major - this.minor = v.minor - this.patch = v.patch + this.components = v.components this.raw = v.raw + this.pretty = v.pretty } else { - this.major = input[0] - this.minor = input[1] - this.patch = input[2] - this.raw = `${this.major}.${this.minor}.${this.patch}` + this.components = input + this.raw = input.join('.') + } + + this.major = this.components[0] + this.minor = this.components[1] ?? 0 + this.patch = this.components[2] ?? 0 + + function char_to_num(c: string) { + return c.charCodeAt(0) - 'a'.charCodeAt(0) + 1 } } toString(): string { - return `${this.major}.${this.minor}.${this.patch}` + return this.pretty ?? + (this.components.length <= 3 + ? `${this.major}.${this.minor}.${this.patch}` + : this.components.join('.')) } eq(that: SemVer): boolean { @@ -55,113 +74,90 @@ export default class SemVer { } neq(that: SemVer): boolean { - return !this.eq(that) + return this.compare(that) != 0 } gt(that: SemVer): boolean { - return this.compare(that) >= 0 + return this.compare(that) > 0 } lt(that: SemVer): boolean { - return this.compare(that) <= 0 + return this.compare(that) < 0 } compare(that: SemVer): number { return _compare(this, that) } - components(): [number, number, number] { - return [this.major, this.minor, this.patch] - } - [Symbol.for("Deno.customInspect")]() { return this.toString() } } -/// more tolerant parser +/// the same as the constructor but swallows the error returning undefined instead export function parse(input: string) { - const v = new SemVer([0,0,0]) - v.raw = input - - let match: RegExpMatchArray | number | null | undefined - - if (match = input.match(/^v?(\d+)\.(\d+)\.(\d+)?$/)) { - v.major = parseInt(match[1])! - v.minor = parseInt(match[2])! - v.patch = parseInt(match[3] ?? '0')! - } else if (match = input.match(/^v?(\d+)\.(\d+)$/)) { - v.major = parseInt(match[1])! - v.minor = parseInt(match[2])! - } else if (match = input.match(/^v?(\d+)$/)) { - v.major = parseInt(match[1])! - } else { + try { + return new SemVer(input) + } catch { return undefined } - - return v } /// we don’t support as much as node-semver but we refuse to do so because it is badness export class Range { // contract [0, 1] where 0 != 1 and 0 < 1 - set: [SemVer, SemVer][] | '*' - raw: string - - constructor(input: string) { - this.raw = input + readonly set: ([SemVer, SemVer] | SemVer)[] | '*' + constructor(input: string | ([SemVer, SemVer] | SemVer)[]) { if (input === "*") { this.set = '*' + } else if (!isString(input)) { + this.set = input } else { input = input.trim() - this.set = input.split(/(?:,|\s*\|\|\s*)/).map(input => { - if (input.startsWith("^")) { - const v1 = parse(input.slice(1))! - const v2 = new SemVer([v1.major + 1,0,0]) - return [v1, v2] - } - - let match = input.match(/^\d+(\.\d+(\.\d+)?)?$/) - if (match) { - const v1 = parse(match[0])! - const v2 = new SemVer(v1.components()) - if (!match[1]) { - v2.major++ - } else if (!match[2]) { - v2.minor++ - } else { - v2.patch++ - } - return [v1, v2] - } - - match = input.match(/^~(\d+(\.\d+(\.\d+)?)?)$/) - if (match) { - const v1 = parse(match[1])! - const v2 = match[2] - ? new SemVer([v1.major, v1.minor + 1, 0]) - : new SemVer([v1.major + 1, 0, 0]) - return [v1, v2] - } + const err = () => new Error(`invalid semver range: ${input}`) - match = input.match(/^>=((\d+\.)*\d+)\s*(<((\d+\.)*\d+))?$/) + this.set = input.split(/(?:,|\s*\|\|\s*)/).map(input => { + let match = input.match(/^>=((\d+\.)*\d+)\s*(<((\d+\.)*\d+))?$/) if (match) { - const v1 = parse(match[1])! - const v2 = match[3] ? parse(match[4])! : new SemVer([Infinity, Infinity, Infinity]) + const v1 = new SemVer(match[1]) + const v2 = match[3] ? new SemVer(match[4])! : new SemVer([Infinity, Infinity, Infinity]) return [v1, v2] + } else if ((match = input.match(/^([~=<^])((\d+)(\.\d+)*)$/))) { + let v1: SemVer | undefined, v2: SemVer | undefined + switch (match[1]) { + case "^": + v1 = new SemVer(match[2]) + v2 = new SemVer([v1.major + 1]) + return [v1, v2] + case "~": { + v1 = new SemVer(match[2]) + if (v1.components.length == 1) { + // yep this is the official policy + v2 = new SemVer([v1.major + 1]) + } else { + v2 = new SemVer([v1.major, v1.minor + 1]) + } + } return [v1, v2] + case "<": + v1 = new SemVer([0]) + v2 = new SemVer(match[2]) + return [v1, v2] + case "=": + return new SemVer(match[2]) + } } - - match = input.match(/^<\d+(\.\d+)*$/) - if (!match) throw new Error(`invalid semver range: \`${input}\``) - - const v1 = new SemVer([0,0,0]) - const v2 = parse(match[0].slice(1)) ?? (() => {throw new Error()})() - return [v1, v2] + throw err() }) - if (this.set.length == 0) throw new Error(`invalid semver range: ${input}`) + if (this.set.length == 0) { + throw err() + } + + for (const i of this.set) { + if (isArray(i) && !i[0].lt(i[1])) throw err() + } } } @@ -169,7 +165,9 @@ export class Range { if (this.set === '*') { return '*' } else { - return this.set.map(([v1, v2]) => { + return this.set.map(v => { + if (!isArray(v)) return `=${v.toString()}` + const [v1, v2] = v if (v2.major == v1.major + 1 && v2.minor == 0 && v2.patch == 0) { const v = chomp(v1) return `^${v}` @@ -180,36 +178,38 @@ export class Range { const v = chomp(v1) return `>=${v}` } else { - const v = this.single() - if (v) { - return `@${v}` - } else { - return `>=${chomp(v1)}<${chomp(v2)}` - } + return `>=${chomp(v1)}<${chomp(v2)}` } }).join(",") } } - eq(that: Range): boolean { - if (this.set.length !== that.set.length) return false - for (let i = 0; i < this.set.length; i++) { - const [a,b] = [this.set[i], that.set[i]] - if (typeof a !== 'string' && typeof b !== 'string') { - if (a[0].neq(b[0])) return false - if (a[1].neq(b[1])) return false - } else if (a != b) { - return false - } - } - return true - } + // eq(that: Range): boolean { + // if (this.set.length !== that.set.length) return false + // for (let i = 0; i < this.set.length; i++) { + // const [a,b] = [this.set[i], that.set[i]] + // if (typeof a !== 'string' && typeof b !== 'string') { + // if (a[0].neq(b[0])) return false + // if (a[1].neq(b[1])) return false + // } else if (a != b) { + // return false + // } + // } + // return true + // } satisfies(version: SemVer): boolean { if (this.set === '*') { return true } else { - return this.set.some(([v1, v2]) => version.compare(v1) >= 0 && version.compare(v2) < 0) + return this.set.some(v => { + if (isArray(v)) { + const [v1, v2] = v + return version.compare(v1) >= 0 && version.compare(v2) < 0 + } else { + return version.eq(v) + } + }) } } @@ -220,10 +220,7 @@ export class Range { single(): SemVer | undefined { if (this.set === '*') return if (this.set.length > 1) return - const [a,b] = this.set[0] - if (a.major != b.major) return - if (a.minor != b.minor) return - if (a.patch == b.patch - 1) return a + return isArray(this.set[0]) ? undefined : this.set[0] } [Symbol.for("Deno.customInspect")]() { @@ -231,11 +228,20 @@ export class Range { } } +function zip(a: T[], b: U[]) { + const N = Math.max(a.length, b.length) + const rv: [T | undefined, U | undefined][] = [] + for (let i = 0; i < N; ++i) { + rv.push([a[i], b[i]]) + } + return rv +} function _compare(a: SemVer, b: SemVer): number { - if (a.major != b.major) return a.major - b.major - if (a.minor != b.minor) return a.minor - b.minor - return a.patch - b.patch + for (const [c,d] of zip(a.components, b.components)) { + if (c != d) return (c ?? 0) - (d ?? 0) + } + return 0 } export { _compare as compare } @@ -243,32 +249,38 @@ export { _compare as compare } export function intersect(a: Range, b: Range): Range { if (b.set === '*') return a if (a.set === '*') return b - if (a.eq(b)) return a // calculate the intersection between two semver.Ranges - const set: [SemVer, SemVer][] = [] - - for (let i = 0; i < a.set.length; i++) { - for (let j = 0; j < b.set.length; j++) { - const a1 = a.set[i][0] - const a2 = a.set[i][1] - const b1 = b.set[j][0] - const b2 = b.set[j][1] + const set: ([SemVer, SemVer] | SemVer)[] = [] + + for (const aa of a.set) { + for (const bb of b.set) { + if (!isArray(aa) && !isArray(bb)) { + if (aa.eq(bb)) set.push(aa) + } else if (!isArray(aa)) { + const bbb = bb as [SemVer, SemVer] + if (aa.compare(bbb[0]) >= 0 && aa.lt(bbb[1])) set.push(aa) + } else if (!isArray(bb)) { + const aaa = aa as [SemVer, SemVer] + if (bb.compare(aaa[0]) >= 0 && bb.lt(aaa[1])) set.push(bb) + } else { + const a1 = aa[0] + const a2 = aa[1] + const b1 = bb[0] + const b2 = bb[1] + + if (a1.compare(b2) >= 0 || b1.compare(a2) >= 0) { + continue + } - if (a1.compare(b2) >= 0 || b1.compare(a2) >= 0) { - continue + set.push([a1.compare(b1) > 0 ? a1 : b1, a2.compare(b2) < 0 ? a2 : b2]) } - - set.push([a1.compare(b1) > 0 ? a1 : b1, a2.compare(b2) < 0 ? a2 : b2]) } } if (set.length <= 0) throw new Error(`cannot intersect: ${a} && ${b}`) - return new Range(set.map(([v1, v2]) => - v2.major == Infinity - ? `>=${chomp(v1)}` - : `>=${chomp(v1)}<${chomp(v2)}`).join(",")) + return new Range(set) } diff --git a/tests/unit/pkgutils.test.ts b/tests/unit/pkgutils.test.ts index 918e5d56..f28d99de 100644 --- a/tests/unit/pkgutils.test.ts +++ b/tests/unit/pkgutils.test.ts @@ -35,13 +35,13 @@ Deno.test("pkg.str", async test => { } await test.step("range of one version", () => { - const constraint = new Range("1.2.3") + const constraint = new Range("=1.2.3") out = pkg.str({ project: "test", constraint }) assert(constraint.single()) - assertEquals(out, `test@1.2.3`) + assertEquals(out, `test=1.2.3`) }) }) diff --git a/tests/unit/semver.test.ts b/tests/unit/semver.test.ts index 93c5ed8d..10dd3c99 100644 --- a/tests/unit/semver.test.ts +++ b/tests/unit/semver.test.ts @@ -4,25 +4,37 @@ import SemVer, * as semver from "utils/semver.ts" Deno.test("semver", async test => { await test.step("sort", () => { - const input = [new SemVer([1,2,3]), new SemVer("2.3.4"), new SemVer("1.2.4")] + const input = [new SemVer([1,2,3]), new SemVer("2.3.4"), new SemVer("1.2.4"), semver.parse("1.2.3.1")!] const sorted1 = input.sort(semver.compare) const sorted2 = input.sort() - assertEquals(sorted1.join(","), "1.2.3,1.2.4,2.3.4") - assertEquals(sorted2.join(","), "1.2.3,1.2.4,2.3.4") + assertEquals(sorted1.join(","), "1.2.3,1.2.3.1,1.2.4,2.3.4") + assertEquals(sorted2.join(","), "1.2.3,1.2.3.1,1.2.4,2.3.4") }) await test.step("parse", () => { + assertEquals(semver.parse("1.2.3.4.5")?.toString(), "1.2.3.4.5") + assertEquals(semver.parse("1.2.3.4")?.toString(), "1.2.3.4") assertEquals(semver.parse("1.2.3")?.toString(), "1.2.3") assertEquals(semver.parse("1.2")?.toString(), "1.2.0") assertEquals(semver.parse("1")?.toString(), "1.0.0") }) await test.step("constructor", () => { + assertEquals(new SemVer("1.2.3.4.5.6").toString(), "1.2.3.4.5.6") + assertEquals(new SemVer("1.2.3.4.5").toString(), "1.2.3.4.5") + assertEquals(new SemVer("1.2.3.4").toString(), "1.2.3.4") assertEquals(new SemVer("1.2.3").toString(), "1.2.3") assertEquals(new SemVer("v1.2.3").toString(), "1.2.3") - assertThrows(() => new SemVer("1.2")) - assertThrows(() => new SemVer("v1.2")) + assertEquals(new SemVer("1.2").toString(), "1.2.0") + assertEquals(new SemVer("v1.2").toString(), "1.2.0") + + assertEquals(new SemVer("1.1.1q").toString(), "1.1.1q") + assertEquals(new SemVer("1.1.1q").components, [1,1,1,17]) + + // we refuse these as it is just too lenient in our opinion + assertEquals(new SemVer("1").toString(), "1.0.0") + assertEquals(new SemVer("v1").toString(), "1.0.0") }) await test.step("ranges", () => { @@ -55,8 +67,12 @@ Deno.test("semver", async test => { assertFalse(d.satisfies(new SemVer("0.16.0"))) assertFalse(d.satisfies(new SemVer("0.14.0"))) + // `~` is weird const e = new semver.Range("~1") - assertEquals(e.toString(), "^1") // indeed: we change the ~ to ^ + assertEquals(e.toString(), "^1") + assert(e.satisfies(new SemVer("1.0"))) + assert(e.satisfies(new SemVer("1.1"))) + assertFalse(e.satisfies(new SemVer("2"))) const f = new semver.Range("^14||^16||^18") assert(f.satisfies(new SemVer("14.0.0"))) @@ -69,17 +85,29 @@ Deno.test("semver", async test => { assert(g.satisfies(new SemVer("14.0.0"))) assert(g.satisfies(new SemVer("0.0.1"))) assertFalse(g.satisfies(new SemVer("15.0.0"))) + + const i = new semver.Range("^1.2.3.4") + assert(i.satisfies(new SemVer("1.2.3.4"))) + assert(i.satisfies(new SemVer("1.2.3.5"))) + assert(i.satisfies(new SemVer("1.2.4.2"))) + assert(i.satisfies(new SemVer("1.3.4.2"))) + assertFalse(i.satisfies(new SemVer("2.0.0"))) + + assertThrows(() => new semver.Range("1")) + assertThrows(() => new semver.Range("1.2")) + assertThrows(() => new semver.Range("1.2.3")) + assertThrows(() => new semver.Range("1.2.3.4")) }) await test.step("intersection", async test => { - await test.step("^3.7…@3.11", () => { + await test.step("^3.7…=3.11", () => { const a = new semver.Range("^3.7") - const b = new semver.Range("3.11.0") + const b = new semver.Range("=3.11") - assertEquals(b.toString(), "@3.11.0") + assertEquals(b.toString(), "=3.11.0") const c = semver.intersect(a, b) - assertEquals(c.toString(), "@3.11.0") + assertEquals(c.toString(), "=3.11.0") }) await test.step("^3.7…^3.9", () => { @@ -108,5 +136,27 @@ Deno.test("semver", async test => { assertThrows(() => semver.intersect(a, b)) }) + + await test.step("^3.7…=3.8", () => { + const a = new semver.Range("^3.7") + const b = new semver.Range("=3.8") + const c = semver.intersect(a, b) + assertEquals(c.toString(), "=3.8.0") + }) + + await test.step("^11,^12…^11.3", () => { + const a = new semver.Range("^11,^12") + const b = new semver.Range("^11.3") + const c = semver.intersect(a, b) + assertEquals(c.toString(), "^11.3") + }) + + //FIXME this *should* work + // await test.step("^11,^12…^11.3,^12.2", () => { + // const a = new semver.Range("^11,^12") + // const b = new semver.Range("^11.3") + // const c = semver.intersect(a, b) + // assertEquals(c.toString(), "^11.3,^12.2") + // }) }) })