/ src / semver.lua
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