From 5341ad6c27d794f51db517e1f52f279190336cb2 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Fri, 16 Jan 2026 10:08:29 +0800 Subject: [PATCH] feat: builtins.compareVersions --- nix-js/runtime-ts/src/builtins/misc.ts | 99 +++++++++++++++++++++++++- nix-js/tests/builtins.rs | 68 ++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/nix-js/runtime-ts/src/builtins/misc.ts b/nix-js/runtime-ts/src/builtins/misc.ts index da9d350..153e2dc 100644 --- a/nix-js/runtime-ts/src/builtins/misc.ts +++ b/nix-js/runtime-ts/src/builtins/misc.ts @@ -5,7 +5,7 @@ import { force } from "../thunk"; import { CatchableError } from "../types"; import type { NixBool, NixStrictValue, NixValue } from "../types"; -import { forceList, forceAttrs, forceFunction } from "../type-assert"; +import { forceList, forceAttrs, forceFunction, forceString } from "../type-assert"; import * as context from "./context"; export const addErrorContext = @@ -48,10 +48,103 @@ export const addDrvOutputDependencies = context.addDrvOutputDependencies; export const compareVersions = (s1: NixValue) => - (s2: NixValue): never => { - throw new Error("Not implemented: compareVersions"); + (s2: NixValue): NixValue => { + const str1 = forceString(s1); + const str2 = forceString(s2); + + let i1 = 0; + let i2 = 0; + + while (i1 < str1.length || i2 < str2.length) { + const c1 = nextComponent(str1, i1); + const c2 = nextComponent(str2, i2); + + i1 = c1.nextIndex; + i2 = c2.nextIndex; + + if (componentsLT(c1.component, c2.component)) { + return -1n; + } else if (componentsLT(c2.component, c1.component)) { + return 1n; + } + } + + return 0n; }; +interface ComponentResult { + component: string; + nextIndex: number; +} + +function nextComponent(s: string, startIdx: number): ComponentResult { + let p = startIdx; + + // Skip any dots and dashes (component separators) + while (p < s.length && (s[p] === "." || s[p] === "-")) { + p++; + } + + if (p >= s.length) { + return { component: "", nextIndex: p }; + } + + const start = p; + + // If the first character is a digit, consume the longest sequence of digits + if (s[p] >= "0" && s[p] <= "9") { + while (p < s.length && s[p] >= "0" && s[p] <= "9") { + p++; + } + } else { + // Otherwise, consume the longest sequence of non-digit, non-separator characters + while ( + p < s.length && + !(s[p] >= "0" && s[p] <= "9") && + s[p] !== "." && + s[p] !== "-" + ) { + p++; + } + } + + return { component: s.substring(start, p), nextIndex: p }; +} + +function componentsLT(c1: string, c2: string): boolean { + const n1 = c1.match(/^[0-9]+$/) ? BigInt(c1) : null; + const n2 = c2.match(/^[0-9]+$/) ? BigInt(c2) : null; + + // Both are numbers: compare numerically + if (n1 !== null && n2 !== null) { + return n1 < n2; + } + + // Empty string < number + if (c1 === "" && n2 !== null) { + return true; + } + + // Special case: "pre" comes before everything except another "pre" + if (c1 === "pre" && c2 !== "pre") { + return true; + } + if (c2 === "pre") { + return false; + } + + // Assume that `2.3a' < `2.3.1' + if (n2 !== null) { + return true; + } + if (n1 !== null) { + return false; + } + + // Both are strings: compare lexicographically + return c1 < c2; +} + export const dirOf = (s: NixValue): never => { throw new Error("Not implemented: dirOf"); }; diff --git a/nix-js/tests/builtins.rs b/nix-js/tests/builtins.rs index dafa28b..ff8a762 100644 --- a/nix-js/tests/builtins.rs +++ b/nix-js/tests/builtins.rs @@ -147,3 +147,71 @@ fn builtins_concat_lists() { ])) ); } + +#[test] +fn builtins_compare_versions_basic() { + assert_eq!(eval("builtins.compareVersions \"1.0\" \"2.3\""), Value::Int(-1)); + assert_eq!(eval("builtins.compareVersions \"2.1\" \"2.3\""), Value::Int(-1)); + assert_eq!(eval("builtins.compareVersions \"2.3\" \"2.3\""), Value::Int(0)); + assert_eq!(eval("builtins.compareVersions \"2.5\" \"2.3\""), Value::Int(1)); + assert_eq!(eval("builtins.compareVersions \"3.1\" \"2.3\""), Value::Int(1)); +} + +#[test] +fn builtins_compare_versions_components() { + assert_eq!(eval("builtins.compareVersions \"2.3.1\" \"2.3\""), Value::Int(1)); + assert_eq!(eval("builtins.compareVersions \"2.3\" \"2.3.1\""), Value::Int(-1)); +} + +#[test] +fn builtins_compare_versions_numeric_vs_alpha() { + // Numeric component comes before alpha component + assert_eq!(eval("builtins.compareVersions \"2.3.1\" \"2.3a\""), Value::Int(1)); + assert_eq!(eval("builtins.compareVersions \"2.3a\" \"2.3.1\""), Value::Int(-1)); +} + +#[test] +fn builtins_compare_versions_pre() { + // "pre" is special: comes before everything except another "pre" + assert_eq!(eval("builtins.compareVersions \"2.3pre1\" \"2.3\""), Value::Int(-1)); + assert_eq!(eval("builtins.compareVersions \"2.3pre3\" \"2.3pre12\""), Value::Int(-1)); + assert_eq!(eval("builtins.compareVersions \"2.3pre1\" \"2.3c\""), Value::Int(-1)); + assert_eq!(eval("builtins.compareVersions \"2.3pre1\" \"2.3q\""), Value::Int(-1)); +} + +#[test] +fn builtins_compare_versions_alpha() { + // Alphabetic comparison + assert_eq!(eval("builtins.compareVersions \"2.3a\" \"2.3c\""), Value::Int(-1)); + assert_eq!(eval("builtins.compareVersions \"2.3c\" \"2.3a\""), Value::Int(1)); +} + +#[test] +fn builtins_compare_versions_symmetry() { + // Test symmetry: compareVersions(a, b) == -compareVersions(b, a) + assert_eq!( + eval("builtins.compareVersions \"1.0\" \"2.3\""), + Value::Int(-1) + ); + assert_eq!( + eval("builtins.compareVersions \"2.3\" \"1.0\""), + Value::Int(1) + ); +} + +#[test] +fn builtins_compare_versions_complex() { + // Complex version strings with multiple components + assert_eq!( + eval("builtins.compareVersions \"1.2.3.4\" \"1.2.3.5\""), + Value::Int(-1) + ); + assert_eq!( + eval("builtins.compareVersions \"1.2.10\" \"1.2.9\""), + Value::Int(1) + ); + assert_eq!( + eval("builtins.compareVersions \"1.2a3\" \"1.2a10\""), + Value::Int(-1) + ); +}