Something went wrong. Try again.
Monorepo for PackTools - tooling for Minecraft Modpacks!
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308--!pw caps=-- @pw/util: small deterministic helpers the other stdlib modules share. Nothing here iterates a-- table in hash order: the stdlib lint refuses `pairs`, `next` and `for k in t`.
local util = {}
--- The keys of `t`, sorted (numbers first, then strings by code point).function util.keys(t: { [any]: any }): { any } local keys = {} for key in pw.sorted(t) do table.insert(keys, key) end return keysend
--- The number of entries in `t`, counting every key.function util.count(t: { [any]: any }): number local n = 0 for _ in pw.sorted(t) do n += 1 end return nend
--- Bytes in the .NET white-space character (`char.IsWhiteSpace`) at byte `i` of `s`, or nil.local function white_at(s: string, i: number): number? local b = string.byte(s, i) if b == nil then return nil elseif (b >= 0x09 and b <= 0x0D) or b == 0x20 then return 1 end local c, d = string.byte(s, i + 1, i + 2) if b == 0xC2 then return if c == 0x85 or c == 0xA0 then 2 else nil elseif b == 0xE1 then return if c == 0x9A and d == 0x80 then 3 else nil elseif b == 0xE2 and c == 0x80 and d ~= nil then return if (d >= 0x80 and d <= 0x8A) or d == 0xA8 or d == 0xA9 or d == 0xAF then 3 else nil elseif b == 0xE2 then return if c == 0x81 and d == 0x9F then 3 else nil elseif b == 0xE3 then return if c == 0x80 and d == 0x80 then 3 else nil end return nilend
--- `s` without the white space it starts with, as .NET's `TrimStart()` (Unicode white space).function util.trim_start(s: string): string local i = 1 while true do local n = white_at(s, i) if n == nil then break end i += n end return string.sub(s, i)end
--- `s` without the white space it ends with, as .NET's `TrimEnd()`.function util.trim_end(s: string): string local j = #s while j > 0 do local begin = j while begin > 1 do local b = string.byte(s, begin) :: number if b < 0x80 or b >= 0xC0 then break end begin -= 1 end local n = white_at(s, begin) if n == nil or begin + n - 1 ~= j then break end j = begin - 1 end return string.sub(s, 1, j)end
--- `s` without white space at either end, as .NET's `Trim()`.function util.trim(s: string): string return util.trim_end(util.trim_start(s))end
--- Whether `s` starts with `prefix`, compared as bytes.function util.starts_with(s: string, prefix: string): boolean return string.sub(s, 1, #prefix) == prefixend
--- Whether `s` ends with `suffix`, compared as bytes.function util.ends_with(s: string, suffix: string): boolean return suffix == "" or string.sub(s, -#suffix) == suffixend
--- `bytes` as UTF-8 text the way Zig's `fmtUtf8` (the WHATWG decoder) reads it: valid text is--- unchanged, and each maximal ill-formed part becomes U+FFFD.function util.lossy(bytes: string): string if string.find(bytes, "[\128-\255]") == nil then return bytes end local out = {} local start = 1 local needed, seen = 0, 0 local lower, upper = 0x80, 0xBF local i = 1 local n = #bytes while i <= n do local b = string.byte(bytes, i) :: number if needed == 0 then start = i if b <= 0x7F then table.insert(out, string.char(b)) elseif b >= 0xC2 and b <= 0xDF then needed = 1 elseif b >= 0xE0 and b <= 0xEF then needed = 2 lower = if b == 0xE0 then 0xA0 else 0x80 upper = if b == 0xED then 0x9F else 0xBF elseif b >= 0xF0 and b <= 0xF4 then needed = 3 lower = if b == 0xF0 then 0x90 else 0x80 upper = if b == 0xF4 then 0x8F else 0xBF else table.insert(out, "\u{FFFD}") end i += 1 elseif b < lower or b > upper then -- The byte is not consumed: it starts over as a lead. needed, seen, lower, upper = 0, 0, 0x80, 0xBF table.insert(out, "\u{FFFD}") else lower, upper = 0x80, 0xBF seen += 1 i += 1 if seen == needed then table.insert(out, string.sub(bytes, start, i - 1)) needed, seen = 0, 0 end end end if needed > 0 then table.insert(out, "\u{FFFD}") end return table.concat(out)end
--- `s` with every occurrence of `needle` replaced by `replacement`, left to right, as plain text.function util.replace_all(s: string, needle: string, replacement: string): string if needle == "" then return s end local parts = {} local from = 1 while true do local at = string.find(s, needle, from, true) if at == nil then break end table.insert(parts, string.sub(s, from, at - 1)) table.insert(parts, replacement) from = at + #needle end table.insert(parts, string.sub(s, from)) return table.concat(parts)end
--- Splits a dotted version ("1.21.10", "0.16.5+build") into its leading numeric parts.local function parts(version: string): { number } local out = {} for digits in string.gmatch(string.match(version, "^[%d%.]+") or "", "%d+") do table.insert(out, tonumber(digits) :: number) end return outend
--- Compares two versions by their numeric parts: -1, 0 or 1. "1.21.10" is above "1.21.9";--- anything after the numeric prefix (a pre-release, build metadata) is ignored.function util.compare_versions(a: string, b: string): number local x, y = parts(a), parts(b) for i = 1, math.max(#x, #y) do local left, right = x[i] or 0, y[i] or 0 if left ~= right then return if left < right then -1 else 1 end end return 0end
--- The version that bounds `~` (next minor) or `^` (next major) above `version`.local function bumped(version: string, index: number): string local p = parts(version) local out = {} for i = 1, index - 1 do out[i] = p[i] or 0 end out[index] = (p[index] or 0) + 1 return table.concat(out, ".")end
--- One Fabric/Quilt predicate term (">=1.21", "~1.21.1", "1.21.x", "*"), or nil if unreadable.local function term_holds(version: string, term: string): boolean? if term == "*" or term == "x" or term == "X" then return true end local op, rest = string.match(term, "^([<>=~%^]*)(.+)$") if rest == nil or not string.match(rest, "^%d[%w%.%-%+]*$") then return nil end if string.match(rest, "[xX%*]") then if op ~= "" and op ~= "=" then return nil end local wanted = string.match(rest, "^([%d%.]-)%.?[xX%*]") if wanted == nil or wanted == "" then return true end local have, need = parts(version), parts(wanted) for i = 1, #need do if have[i] ~= need[i] then return false end end return true end local c = util.compare_versions(version, rest) if op == "" or op == "=" then return c == 0 elseif op == ">=" then return c >= 0 elseif op == ">" then return c > 0 elseif op == "<=" then return c <= 0 elseif op == "<" then return c < 0 elseif op == "~" then return c >= 0 and util.compare_versions(version, bumped(rest, 2)) < 0 elseif op == "^" then return c >= 0 and util.compare_versions(version, bumped(rest, 1)) < 0 end return nilend
--- A Maven range ("[1.21,1.22)", "[1.20.1]", "[1.20,)", unions joined by commas), or nil.local function maven_holds(version: string, range: string): boolean? local matched = false local any = false for open, inside, close in string.gmatch(range, "([%[%(])([^%]%)]*)([%]%)])") do any = true local lower, upper = string.match(inside, "^%s*(.-)%s*,%s*(.-)%s*$") local ok if lower == nil then ok = util.compare_versions(version, (string.gsub(inside, "%s", ""))) == 0 else ok = true if lower ~= "" then local c = util.compare_versions(version, lower) ok = ok and (if open == "[" then c >= 0 else c > 0) end if upper ~= "" then local c = util.compare_versions(version, upper) ok = ok and (if close == "]" then c <= 0 else c < 0) end end matched = matched or ok end if not any then return nil end return matchedend
--- Whether `version` meets `range` as a mod declares it. `syntax` is "maven" (Forge, NeoForge:--- bracketed ranges, and a bare version is only a recommendation, so it accepts anything) or--- "semver" (Fabric, Quilt: space-separated terms all hold, `||` between alternatives); without--- it, a bracket means Maven. Nil when the range is unreadable.function util.satisfies(version: string, range: string, syntax: string?): boolean? local text = string.match(range, "^%s*(.-)%s*$") or "" if text == "" or text == "*" then return true end local first = string.sub(text, 1, 1) if first == "[" or first == "(" then return maven_holds(version, text) end if syntax == "maven" then return true end for alternative in string.gmatch(text .. "||", "(.-)||") do local all = true for term in string.gmatch(alternative, "%S+") do local holds = term_holds(version, term) if holds == nil then return nil end all = all and holds end if all then return true end end return falseend
return util