semver.lua
1 --[[ 2 ToasterGen Spin 3 4 Copyright (C) 2025 Clifton Toaster Reid <cliftontreid@duck.com> 5 6 This library is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Lesser General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 11 This library is distributed in the hope that it will be useful, 12 but WITHOUT ANY WARRANTY; without even the implied warranty of 13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 GNU Lesser General Public License for more details. 15 16 You should have received a copy of the GNU Lesser General Public License 17 along with this library. If not, see <https://www.gnu.org/licenses/>. 18 ]] 19 20 --- Semantic Versioning (SemVer) Module with Extended Utility Functions. 21 --- Implements parsing, comparing, converting, and additional utility functions for semver strings. 22 --- For full details on semantic versioning, see: https://semver.org 23 24 local semver = {} 25 26 --- @class SemVer 27 --- Represents a semantic version. 28 --- @field major number The major version. 29 --- @field minor number The minor version. 30 --- @field patch number The patch version. 31 --- @field prerelease string|nil Optional pre-release identifier. 32 --- @field build string|nil Optional build metadata. 33 34 --- Parses a semantic version string into a SemVer object. 35 --- The accepted format is "MAJOR.MINOR.PATCH", with optional pre-release and build metadata. 36 --- Examples: 37 --- "1.2.3" => major=1, minor=2, patch=3 38 --- "1.2.3-alpha" => prerelease="alpha" 39 --- "1.2.3-alpha+001" => prerelease="alpha", build="001" 40 --- @param version_str string The semantic version string to parse. 41 --- @return SemVer|nil The parsed version table, or nil if the format is invalid. 42 --- @return string|nil Error message if parsing fails. 43 function semver.parse(version_str) 44 if type(version_str) ~= "string" then 45 return nil, "Version must be a string" 46 end 47 48 -- Split by "+" to extract build metadata 49 local version_part, build 50 local plus_pos = version_str:find("%+") 51 if plus_pos then 52 version_part = version_str:sub(1, plus_pos - 1) 53 build = version_str:sub(plus_pos + 1) 54 55 if #build == 0 then 56 return nil, "Build metadata cannot be empty" 57 end 58 else 59 version_part = version_str 60 end 61 62 -- Split by "-" to extract prerelease 63 local main_version, prerelease 64 local hyphen_pos = version_part:find("%-") 65 if hyphen_pos then 66 main_version = version_part:sub(1, hyphen_pos - 1) 67 prerelease = version_part:sub(hyphen_pos + 1) 68 69 if #prerelease == 0 then 70 return nil, "Prerelease identifier cannot be empty" 71 end 72 else 73 main_version = version_part 74 end 75 76 -- Parse major.minor.patch 77 local parts = {} 78 for part in main_version:gmatch("[^%.]+") do 79 table.insert(parts, part) 80 end 81 82 if #parts ~= 3 then 83 return nil, "Version must have exactly three numeric parts: major.minor.patch" 84 end 85 86 -- Validate and convert numeric parts 87 local major, minor, patch 88 for i, part in ipairs(parts) do 89 -- Check for non-digits 90 if part:match("[^0-9]") then 91 return nil, "Version parts must contain only digits" 92 end 93 94 -- Check for leading zeros (except when value is 0) 95 if #part > 1 and part:sub(1, 1) == "0" then 96 return nil, "Version parts cannot have leading zeros" 97 end 98 99 local num = tonumber(part) 100 if not num then 101 return nil, "Failed to convert version part to number" 102 end 103 104 if i == 1 then 105 major = num 106 elseif i == 2 then 107 minor = num 108 else 109 patch = num 110 end 111 end 112 113 -- Validate prerelease format if present 114 if prerelease then 115 for identifier in prerelease:gmatch("[^%.]+") do 116 if #identifier == 0 then 117 return nil, "Prerelease identifiers cannot be empty" 118 end 119 120 -- Must contain only alphanumerics and hyphens 121 if identifier:match("[^0-9A-Za-z%-]") then 122 return nil, "Prerelease identifiers must contain only alphanumerics and hyphens" 123 end 124 125 -- Numeric identifiers cannot have leading zeros 126 local num = tonumber(identifier) 127 if num and #identifier > 1 and identifier:sub(1, 1) == "0" then 128 return nil, "Numeric prerelease identifiers cannot have leading zeros" 129 end 130 end 131 end 132 133 -- Validate build metadata format if present 134 if build then 135 for identifier in build:gmatch("[^%.]+") do 136 if #identifier == 0 then 137 return nil, "Build metadata identifiers cannot be empty" 138 end 139 140 -- Must contain only alphanumerics and hyphens 141 if identifier:match("[^0-9A-Za-z%-]") then 142 return nil, "Build metadata identifiers must contain only alphanumerics and hyphens" 143 end 144 end 145 end 146 147 return { 148 major = major, 149 minor = minor, 150 patch = patch, 151 prerelease = prerelease, 152 build = build, 153 } 154 end 155 156 --- Compares two SemVer objects. 157 --- Comparison follows semver precedence rules: 158 --- 1. Compare major, minor, then patch numbers. 159 --- 2. A version without a prerelease field has higher precedence than one with a prerelease. 160 --- 3. If both have prerelease values, compare them by splitting into dot-separated identifiers. 161 --- @param v1 SemVer The first version. 162 --- @param v2 SemVer The second version. 163 --- @return number Returns 1 if v1 > v2, -1 if v1 < v2, or 0 if both are equal. 164 function semver.compare(v1, v2) 165 if v1.major ~= v2.major then 166 return v1.major > v2.major and 1 or -1 167 end 168 if v1.minor ~= v2.minor then 169 return v1.minor > v2.minor and 1 or -1 170 end 171 if v1.patch ~= v2.patch then 172 return v1.patch > v2.patch and 1 or -1 173 end 174 175 -- When numeric parts arINFOe equal, handle prerelease. 176 if v1.prerelease == v2.prerelease then 177 return 0 178 elseif v1.prerelease == nil then 179 return 1 -- a version without prerelease is higher 180 elseif v2.prerelease == nil then 181 return -1 182 else 183 -- Split prerelease string by dot. 184 local function split(str) 185 local t = {} 186 for part in string.gmatch(str, "([^%.]+)") do 187 table.insert(t, part) 188 end 189 return t 190 end 191 192 local pre1 = split(v1.prerelease) 193 local pre2 = split(v2.prerelease) 194 local len = math.max(#pre1, #pre2) 195 for i = 1, len do 196 local a = pre1[i] 197 local b = pre2[i] 198 if a == nil then 199 return -1 200 elseif b == nil then 201 return 1 202 else 203 local na = tonumber(a) 204 local nb = tonumber(b) 205 if na and nb then 206 if na ~= nb then 207 return na > nb and 1 or -1 208 end 209 elseif na then 210 -- Numeric identifiers have lower precedence than non-numeric. 211 return -1 212 elseif nb then 213 return 1 214 else 215 if a ~= b then 216 return a > b and 1 or -1 217 end 218 end 219 end 220 end 221 return 0 222 end 223 end 224 225 --- Converts a SemVer object back to its string representation. 226 --- @param version SemVer The version object. 227 --- @return string The semantic version string. 228 function semver.toString(version) 229 local str = string.format("%d.%d.%d", version.major, version.minor, version.patch) 230 if version.prerelease then 231 str = str .. "-" .. version.prerelease 232 end 233 if version.build then 234 str = str .. "+" .. version.build 235 end 236 return str 237 end 238 239 -- Utility functions for comparing parsed SemVer objects 240 241 --- Checks if two SemVer objects are equal. 242 --- @param v1 SemVer The first version. 243 --- @param v2 SemVer The second version. 244 --- @return boolean True if equal, false otherwise. 245 function semver.eq(v1, v2) 246 return semver.compare(v1, v2) == 0 247 end 248 249 --- Checks if the first SemVer object is greater than the second. 250 --- @param v1 SemVer The first version. 251 --- @param v2 SemVer The second version. 252 --- @return boolean True if v1 > v2, false otherwise. 253 function semver.gt(v1, v2) 254 return semver.compare(v1, v2) == 1 255 end 256 257 --- Checks if the first SemVer object is less than the second. 258 --- @param v1 SemVer The first version. 259 --- @param v2 SemVer The second version. 260 --- @return boolean True if v1 < v2, false otherwise. 261 function semver.lt(v1, v2) 262 return semver.compare(v1, v2) == -1 263 end 264 265 --- Checks if the first SemVer object is greater than or equal to the second. 266 --- @param v1 SemVer The first version. 267 --- @param v2 SemVer The second version. 268 --- @return boolean True if v1 >= v2, false otherwise. 269 function semver.ge(v1, v2) 270 local cmp = semver.compare(v1, v2) 271 return cmp == 1 or cmp == 0 272 end 273 274 --- Checks if the first SemVer object is less than or equal to the second. 275 --- @param v1 SemVer The first version. 276 --- @param v2 SemVer The second version. 277 --- @return boolean True if v1 <= v2, false otherwise. 278 function semver.le(v1, v2) 279 local cmp = semver.compare(v1, v2) 280 return cmp == -1 or cmp == 0 281 end 282 283 -- Utility functions for comparing version strings directly 284 285 --- Compares two semantic version strings. 286 --- @param version1 string A semantic version string. 287 --- @param version2 string A semantic version string. 288 --- @return number|nil Returns 1 if version1 > version2, -1 if version1 < version2, 0 if equal, 289 --- or nil with an error message if a version string is invalid. 290 --- @return string|nil Error message if a version string is invalid. 291 function semver.compare_strings(version1, version2) 292 local v1, err1 = semver.parse(version1) 293 if not v1 then 294 return nil, "Invalid version1: " .. err1 295 end 296 local v2, err2 = semver.parse(version2) 297 if not v2 then 298 return nil, "Invalid version2: " .. err2 299 end 300 return semver.compare(v1, v2) 301 end 302 303 --- Checks if two semantic version strings are equal. 304 --- @param version1 string A semantic version string. 305 --- @param version2 string A semantic version string. 306 --- @return boolean|nil True if equal, false if not, or nil with an error message if a version string is invalid. 307 --- @return string|nil Error message if a version string is invalid. 308 function semver.eq_str(version1, version2) 309 local cmp, err = semver.compare_strings(version1, version2) 310 if cmp == nil then 311 return nil, err 312 end 313 return cmp == 0 314 end 315 316 --- Checks if the first semantic version string is greater than the second. 317 --- @param version1 string A semantic version string. 318 --- @param version2 string A semantic version string. 319 --- @return boolean|nil True if version1 > version2, false if not, or nil with an error message if a version string is invalid. 320 --- @return string|nil Error message if a version string is invalid. 321 function semver.gt_str(version1, version2) 322 local cmp, err = semver.compare_strings(version1, version2) 323 if cmp == nil then 324 return nil, err 325 end 326 return cmp == 1 327 end 328 329 --- Checks if the first semantic version string is less than the second. 330 --- @param version1 string A semantic version string. 331 --- @param version2 string A semantic version string. 332 --- @return boolean|nil True if version1 < version2, false if not, or nil with an error message if a version string is invalid. 333 --- @return string|nil Error message if a version string is invalid. 334 function semver.lt_str(version1, version2) 335 local cmp, err = semver.compare_strings(version1, version2) 336 if cmp == nil then 337 return nil, err 338 end 339 return cmp == -1 340 end 341 342 --- Sorts an array of semantic version strings. 343 --- @param versions table Array of semantic version strings. 344 --- @return table|nil Sorted array of semantic version strings, or nil with an error message if any version is invalid. 345 --- @return string|nil Error message if a version string is invalid. 346 function semver.sort(versions) 347 local parsed_versions = {} 348 for i, ver_str in ipairs(versions) do 349 local v, err = semver.parse(ver_str) 350 if not v then 351 return nil, "Invalid version at index " .. i .. ": " .. err 352 end 353 parsed_versions[i] = { original = ver_str, parsed = v } 354 end 355 356 table.sort(parsed_versions, function(a, b) 357 return semver.compare(a.parsed, b.parsed) < 0 358 end) 359 360 local sorted_versions = {} 361 for i, entry in ipairs(parsed_versions) do 362 sorted_versions[i] = entry.original 363 end 364 return sorted_versions 365 end 366 367 return semver