mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
Version range compression utils (#2840)
* DNF normalization wip * a bunch of wip stuff * it is alive! * tests * deduplicate strings in tests * fix != flavor behavior & parse flavor constraints & equals shorthand for normalize * use normalization * more comments & fix tests not running because of bad rebase * fix comments+tests * slightly better comment * fix dependency & typos --------- Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { DeepMap } from "deep-equality-data-structures";
|
||||
import * as P from "./exver"
|
||||
|
||||
// prettier-ignore
|
||||
export type ValidateVersion<T extends String> =
|
||||
export type ValidateVersion<T extends String> =
|
||||
T extends `-${infer A}` ? never :
|
||||
T extends `${infer A}-${string}` ? ValidateVersion<A> :
|
||||
T extends `${bigint}` ? unknown :
|
||||
@@ -9,9 +10,9 @@ T extends `${infer A}-${string}` ? ValidateVersion<A> :
|
||||
never
|
||||
|
||||
// prettier-ignore
|
||||
export type ValidateExVer<T extends string> =
|
||||
export type ValidateExVer<T extends string> =
|
||||
T extends `#${string}:${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
||||
T extends `${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
||||
T extends `${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
||||
never
|
||||
|
||||
// prettier-ignore
|
||||
@@ -43,19 +44,385 @@ type Not = {
|
||||
value: VersionRange
|
||||
}
|
||||
|
||||
type Flavor = {
|
||||
type: "Flavor",
|
||||
flavor: string | null,
|
||||
}
|
||||
|
||||
type FlavorNot = {
|
||||
type: "FlavorNot",
|
||||
flavors: Set<string | null>,
|
||||
}
|
||||
|
||||
type FlavorAtom = Flavor | FlavorNot;
|
||||
|
||||
/**
|
||||
* Splits a number line of versions in half, so that every possible semver is either to the left or right.
|
||||
* The `side` field handles inclusively.
|
||||
*
|
||||
* # Example
|
||||
* Consider the version `1.2.3`. For side=-1 the version point is like `1.2.2.999*.999*.**` (that is, 1.2.3.0.0.** is greater) and
|
||||
* for side=+1 the point is like `1.2.3.0.0.**.1` (that is, 1.2.3.0.0.** is less).
|
||||
*/
|
||||
type VersionRangePoint = {
|
||||
upstream: Version,
|
||||
downstream: Version,
|
||||
side: -1 | 1;
|
||||
}
|
||||
|
||||
function compareVersionRangePoints(a: VersionRangePoint, b: VersionRangePoint): -1 | 0 | 1 {
|
||||
let up = a.upstream.compareForSort(b.upstream);
|
||||
if (up != 0) {
|
||||
return up;
|
||||
}
|
||||
let down = a.upstream.compareForSort(b.upstream);
|
||||
if (down != 0) {
|
||||
return down;
|
||||
}
|
||||
if (a.side < b.side) {
|
||||
return -1;
|
||||
} else if (a.side > b.side) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function adjacentVersionRangePoints(a: VersionRangePoint, b: VersionRangePoint): boolean {
|
||||
let up = a.upstream.compareForSort(b.upstream);
|
||||
if (up != 0) {
|
||||
return false;
|
||||
}
|
||||
let down = a.upstream.compareForSort(b.upstream);
|
||||
if (down != 0) {
|
||||
return false;
|
||||
}
|
||||
return a.side == -1 && b.side == 1;
|
||||
}
|
||||
|
||||
function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
|
||||
if (a.type == 'Flavor') {
|
||||
if (b.type == 'Flavor') {
|
||||
if (a.flavor == b.flavor) {
|
||||
return a;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (b.flavors.has(a.flavor)) {
|
||||
return null;
|
||||
} else {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (b.type == 'Flavor') {
|
||||
if (a.flavors.has(b.flavor)) {
|
||||
return null;
|
||||
} else {
|
||||
return b;
|
||||
}
|
||||
} else {
|
||||
// TODO: use Set.union if targeting esnext or later
|
||||
return { type: 'FlavorNot', flavors: new Set([...a.flavors, ...b.flavors]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truth tables for version numbers and flavors. For each flavor we need a separate table, which
|
||||
* is quite straightforward. But in order to exhaustively enumerate the boolean values of every
|
||||
* combination of flavors and versions we also need tables for flavor negations.
|
||||
*/
|
||||
type VersionRangeTables = DeepMap<FlavorAtom, VersionRangeTable> | boolean;
|
||||
|
||||
/**
|
||||
* A truth table for version numbers. This is easiest to picture as a number line, cut up into
|
||||
* ranges of versions between version points.
|
||||
*/
|
||||
class VersionRangeTable {
|
||||
private constructor(protected points: Array<VersionRangePoint>, protected values: boolean[]) {}
|
||||
|
||||
static zip(a: VersionRangeTable, b: VersionRangeTable, func: (a: boolean, b: boolean) => boolean): VersionRangeTable {
|
||||
let c = new VersionRangeTable([], []);
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (true) {
|
||||
let next = func(a.values[i], b.values[j]);
|
||||
if (c.values.length > 0 && c.values[c.values.length - 1] == next) {
|
||||
// collapse automatically
|
||||
c.points.pop();
|
||||
} else {
|
||||
c.values.push(next);
|
||||
}
|
||||
|
||||
// which point do we step over?
|
||||
if (i == a.points.length) {
|
||||
if (j == b.points.length) {
|
||||
// just added the last segment, no point to jump over
|
||||
return c;
|
||||
} else {
|
||||
// i has reach the end, step over j
|
||||
c.points.push(b.points[j]);
|
||||
j += 1;
|
||||
}
|
||||
} else {
|
||||
if (j == b.points.length) {
|
||||
// j has reached the end, step over i
|
||||
c.points.push(a.points[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
// depends on which of the next two points is lower
|
||||
switch (compareVersionRangePoints(a.points[i], b.points[j])) {
|
||||
case -1:
|
||||
// i is the lower point
|
||||
c.points.push(a.points[i]);
|
||||
i += 1;
|
||||
break;
|
||||
case 1:
|
||||
// j is the lower point
|
||||
c.points.push(b.points[j]);
|
||||
j += 1;
|
||||
break;
|
||||
default:
|
||||
// step over both
|
||||
c.points.push(a.points[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a version table which is `true` for the given flavor, and `false` for any other flavor.
|
||||
*/
|
||||
static eqFlavor(flavor: string | null): VersionRangeTables {
|
||||
return new DeepMap([
|
||||
[{ type: 'Flavor', flavor } as FlavorAtom, new VersionRangeTable([], [true])],
|
||||
// make sure the truth table is exhaustive, or `not` will not work properly.
|
||||
[{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom, new VersionRangeTable([], [false])],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a version table with exactly two ranges (to the left and right of the given point) and with `false` for any other flavor.
|
||||
* This is easiest to understand by looking at `VersionRange.tables`.
|
||||
*/
|
||||
static cmpPoint(flavor: string | null, point: VersionRangePoint, left: boolean, right: boolean): VersionRangeTables {
|
||||
return new DeepMap([
|
||||
[{ type: 'Flavor', flavor } as FlavorAtom, new VersionRangeTable([point], [left, right])],
|
||||
// make sure the truth table is exhaustive, or `not` will not work properly.
|
||||
[{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom, new VersionRangeTable([], [false])],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for `cmpPoint`.
|
||||
*/
|
||||
static cmp(version: ExtendedVersion, side: -1 | 1, left: boolean, right: boolean): VersionRangeTables {
|
||||
return VersionRangeTable.cmpPoint(version.flavor, { upstream: version.upstream, downstream: version.downstream, side }, left, right)
|
||||
}
|
||||
|
||||
static not(tables: VersionRangeTables) {
|
||||
if (tables === true || tables === false) {
|
||||
return !tables;
|
||||
}
|
||||
// because tables are always exhaustive, we can simply invert each range
|
||||
for (let [f, t] of tables) {
|
||||
for (let i = 0; i < t.values.length; i++) {
|
||||
t.values[i] = !t.values[i];
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
static and(a_tables: VersionRangeTables, b_tables: VersionRangeTables): VersionRangeTables {
|
||||
if (a_tables === true) {
|
||||
return b_tables;
|
||||
}
|
||||
if (b_tables === true) {
|
||||
return a_tables;
|
||||
}
|
||||
if (a_tables === false || b_tables == false) {
|
||||
return false;
|
||||
}
|
||||
let c_tables: VersionRangeTables = true;
|
||||
for (let [f_a, a] of a_tables) {
|
||||
for (let [f_b, b] of b_tables) {
|
||||
let flavor = flavorAnd(f_a, f_b);
|
||||
if (flavor == null) {
|
||||
continue;
|
||||
}
|
||||
let c = VersionRangeTable.zip(a, b, (a, b) => a && b);
|
||||
if (c_tables === true) {
|
||||
c_tables = new DeepMap();
|
||||
}
|
||||
let prev_c = c_tables.get(flavor);
|
||||
if (prev_c == null) {
|
||||
c_tables.set(flavor, c);
|
||||
} else {
|
||||
c_tables.set(flavor, VersionRangeTable.zip(c, prev_c, (a, b) => a || b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return c_tables;
|
||||
}
|
||||
|
||||
static or(...in_tables: VersionRangeTables[]): VersionRangeTables {
|
||||
let out_tables: VersionRangeTables = false;
|
||||
for (let tables of in_tables) {
|
||||
if (tables === false) {
|
||||
continue;
|
||||
}
|
||||
if (tables === true) {
|
||||
return true;
|
||||
}
|
||||
if (out_tables === false) {
|
||||
out_tables = new DeepMap();
|
||||
}
|
||||
for (let [flavor, table] of tables) {
|
||||
let prev = out_tables.get(flavor);
|
||||
if (prev == null) {
|
||||
out_tables.set(flavor, table);
|
||||
} else {
|
||||
out_tables.set(flavor, VersionRangeTable.zip(table, prev, (a, b) => a || b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out_tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* If this is true for all versions or false for all versions, returen that value. Otherwise return null.
|
||||
*/
|
||||
static collapse(tables: VersionRangeTables): boolean | null {
|
||||
if (tables === true || tables === false) {
|
||||
return tables;
|
||||
} else {
|
||||
let found = null;
|
||||
for (let table of tables.values()) {
|
||||
for (let x of table.values) {
|
||||
if (found == null) {
|
||||
found = x;
|
||||
} else if (found != x) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expresses this truth table as a series of version range operators.
|
||||
* https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms
|
||||
*/
|
||||
static minterms(tables: VersionRangeTables): VersionRange {
|
||||
let collapse = VersionRangeTable.collapse(tables);
|
||||
if (tables === true || collapse === true) {
|
||||
return VersionRange.any()
|
||||
}
|
||||
if (tables == false || collapse === false) {
|
||||
return VersionRange.none()
|
||||
}
|
||||
let sum_terms: VersionRange[] = [];
|
||||
for (let [flavor, table] of tables) {
|
||||
let cmp_flavor = null;
|
||||
if (flavor.type == 'Flavor') {
|
||||
cmp_flavor = flavor.flavor;
|
||||
}
|
||||
for (let i = 0; i < table.values.length; i++) {
|
||||
let term: VersionRange[] = [];
|
||||
if (!table.values[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (flavor.type == 'FlavorNot') {
|
||||
for (let not_flavor of flavor.flavors) {
|
||||
term.push(VersionRange.flavor(not_flavor).not());
|
||||
}
|
||||
}
|
||||
|
||||
let p = null;
|
||||
let q = null;
|
||||
if (i > 0) {
|
||||
p = table.points[i - 1];
|
||||
}
|
||||
if (i < table.points.length) {
|
||||
q = table.points[i];
|
||||
}
|
||||
|
||||
if (p != null && q != null && adjacentVersionRangePoints(p, q)) {
|
||||
term.push(VersionRange.anchor('=', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream)));
|
||||
} else {
|
||||
if (p != null && p.side < 0) {
|
||||
term.push(VersionRange.anchor('>=', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream)));
|
||||
}
|
||||
if (p != null && p.side >= 0) {
|
||||
term.push(VersionRange.anchor('>', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream)));
|
||||
}
|
||||
if (q != null && q.side < 0) {
|
||||
term.push(VersionRange.anchor('<', new ExtendedVersion(cmp_flavor, q.upstream, q.downstream)));
|
||||
}
|
||||
if (q != null && q.side >= 0) {
|
||||
term.push(VersionRange.anchor('<=', new ExtendedVersion(cmp_flavor, q.upstream, q.downstream)));
|
||||
}
|
||||
}
|
||||
|
||||
if (term.length == 0) {
|
||||
term.push(VersionRange.flavor(cmp_flavor));
|
||||
}
|
||||
|
||||
sum_terms.push(VersionRange.and(...term));
|
||||
}
|
||||
}
|
||||
return VersionRange.or(...sum_terms);
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionRange {
|
||||
private constructor(public atom: Anchor | And | Or | Not | P.Any | P.None) {}
|
||||
constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {}
|
||||
|
||||
toStringParens(parent: "And" | "Or" | "Not") {
|
||||
let needs = true;
|
||||
switch (this.atom.type) {
|
||||
case "And":
|
||||
case "Or":
|
||||
needs = parent != this.atom.type;
|
||||
break
|
||||
case "Anchor":
|
||||
case "Any":
|
||||
case "None":
|
||||
needs = parent == "Not"
|
||||
break
|
||||
case "Not":
|
||||
case "Flavor":
|
||||
needs = false;
|
||||
break
|
||||
}
|
||||
|
||||
if (needs) {
|
||||
return "(" + this.toString() + ")";
|
||||
} else {
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
switch (this.atom.type) {
|
||||
case "Anchor":
|
||||
return `${this.atom.operator}${this.atom.version}`
|
||||
case "And":
|
||||
return `(${this.atom.left.toString()}) && (${this.atom.right.toString()})`
|
||||
return `${this.atom.left.toStringParens(this.atom.type)} && ${this.atom.right.toStringParens(this.atom.type)}`
|
||||
case "Or":
|
||||
return `(${this.atom.left.toString()}) || (${this.atom.right.toString()})`
|
||||
return `${this.atom.left.toStringParens(this.atom.type)} || ${this.atom.right.toStringParens(this.atom.type)}`
|
||||
case "Not":
|
||||
return `!(${this.atom.value.toString()})`
|
||||
return `!${this.atom.value.toStringParens(this.atom.type)}`
|
||||
case "Flavor":
|
||||
return this.atom.flavor == null ? `#` : `#${this.atom.flavor}`
|
||||
case "Any":
|
||||
return "*"
|
||||
case "None":
|
||||
@@ -88,6 +455,8 @@ export class VersionRange {
|
||||
),
|
||||
),
|
||||
})
|
||||
case "Flavor":
|
||||
return VersionRange.flavor(atom.flavor)
|
||||
default:
|
||||
return new VersionRange(atom)
|
||||
}
|
||||
@@ -123,6 +492,14 @@ export class VersionRange {
|
||||
)
|
||||
}
|
||||
|
||||
static anchor(operator: P.CmpOp, version: ExtendedVersion) {
|
||||
return new VersionRange({ type: "Anchor", operator, version })
|
||||
}
|
||||
|
||||
static flavor(flavor: string | null) {
|
||||
return new VersionRange({ type: "Flavor", flavor })
|
||||
}
|
||||
|
||||
static parseEmver(range: string): VersionRange {
|
||||
return VersionRange.parseRange(
|
||||
P.parse(range, { startRule: "EmverVersionRange" }),
|
||||
@@ -141,8 +518,40 @@ export class VersionRange {
|
||||
return new VersionRange({ type: "Not", value: this })
|
||||
}
|
||||
|
||||
static anchor(operator: P.CmpOp, version: ExtendedVersion) {
|
||||
return new VersionRange({ type: "Anchor", operator, version })
|
||||
static and(...xs: Array<VersionRange>) {
|
||||
let y = VersionRange.any();
|
||||
for (let x of xs) {
|
||||
if (x.atom.type == 'Any') {
|
||||
continue;
|
||||
}
|
||||
if (x.atom.type == 'None') {
|
||||
return x;
|
||||
}
|
||||
if (y.atom.type == 'Any') {
|
||||
y = x;
|
||||
} else {
|
||||
y = new VersionRange({ type: 'And', left: y, right: x});
|
||||
}
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
static or(...xs: Array<VersionRange>) {
|
||||
let y = VersionRange.none();
|
||||
for (let x of xs) {
|
||||
if (x.atom.type == 'None') {
|
||||
continue;
|
||||
}
|
||||
if (x.atom.type == 'Any') {
|
||||
return x;
|
||||
}
|
||||
if (y.atom.type == 'None') {
|
||||
y = x;
|
||||
} else {
|
||||
y = new VersionRange({ type: 'Or', left: y, right: x});
|
||||
}
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
static any() {
|
||||
@@ -156,6 +565,71 @@ export class VersionRange {
|
||||
satisfiedBy(version: Version | ExtendedVersion) {
|
||||
return version.satisfies(this)
|
||||
}
|
||||
|
||||
tables(): VersionRangeTables {
|
||||
switch(this.atom.type) {
|
||||
case "Anchor":
|
||||
switch (this.atom.operator) {
|
||||
case "=":
|
||||
// `=1.2.3` is equivalent to `>=1.2.3 && <=1.2.4 && #flavor`
|
||||
return VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
VersionRangeTable.cmp(this.atom.version, 1, true, false),
|
||||
)
|
||||
case ">":
|
||||
return VersionRangeTable.cmp(this.atom.version, 1, false, true)
|
||||
case "<":
|
||||
return VersionRangeTable.cmp(this.atom.version, -1, true, false)
|
||||
case ">=":
|
||||
return VersionRangeTable.cmp(this.atom.version, -1, false, true)
|
||||
case "<=":
|
||||
return VersionRangeTable.cmp(this.atom.version, 1, true, false)
|
||||
case "!=":
|
||||
// `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)`
|
||||
// **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor`
|
||||
return VersionRangeTable.not(VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
VersionRangeTable.cmp(this.atom.version, 1, true, false),
|
||||
))
|
||||
case "^":
|
||||
// `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor`
|
||||
return VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
VersionRangeTable.cmp(this.atom.version.incrementMajor(), -1, true, false),
|
||||
)
|
||||
case "~":
|
||||
// `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor`
|
||||
return VersionRangeTable.and(
|
||||
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
||||
VersionRangeTable.cmp(this.atom.version.incrementMinor(), -1, true, false),
|
||||
)
|
||||
}
|
||||
case "Flavor":
|
||||
return VersionRangeTable.eqFlavor(this.atom.flavor)
|
||||
case "Not":
|
||||
return VersionRangeTable.not(this.atom.value.tables())
|
||||
case "And":
|
||||
return VersionRangeTable.and(this.atom.left.tables(), this.atom.right.tables())
|
||||
case "Or":
|
||||
return VersionRangeTable.or(this.atom.left.tables(), this.atom.right.tables())
|
||||
case "Any":
|
||||
return true
|
||||
case "None":
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
satisfiable(): boolean {
|
||||
return VersionRangeTable.collapse(this.tables()) !== false;
|
||||
}
|
||||
|
||||
intersects(other: VersionRange): boolean {
|
||||
return VersionRange.and(this, other).satisfiable();
|
||||
}
|
||||
|
||||
normalize(): VersionRange {
|
||||
return VersionRangeTable.minterms(this.tables());
|
||||
}
|
||||
}
|
||||
|
||||
export class Version {
|
||||
@@ -211,6 +685,17 @@ export class Version {
|
||||
return "equal"
|
||||
}
|
||||
|
||||
compareForSort(other: Version): -1 | 0 | 1 {
|
||||
switch (this.compare(other)) {
|
||||
case "greater":
|
||||
return 1
|
||||
case "equal":
|
||||
return 0
|
||||
case "less":
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
static parse(version: string): Version {
|
||||
const parsed = P.parse(version, { startRule: "Version" })
|
||||
return new Version(parsed.number, parsed.prerelease)
|
||||
@@ -409,6 +894,8 @@ export class ExtendedVersion {
|
||||
return false
|
||||
}
|
||||
}
|
||||
case "Flavor":
|
||||
return versionRange.atom.flavor == this.flavor
|
||||
case "And":
|
||||
return (
|
||||
this.satisfies(versionRange.atom.left) &&
|
||||
@@ -433,6 +920,7 @@ export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t
|
||||
|
||||
export const testTypeVersion = <T extends string>(t: T & ValidateVersion<T>) =>
|
||||
t
|
||||
|
||||
function tests() {
|
||||
testTypeVersion("1.2.3")
|
||||
testTypeVersion("1")
|
||||
|
||||
Reference in New Issue
Block a user