From 3e3a69c20eea24176f2b09e95826f9c74f20be8c Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Sat, 9 May 2026 20:09:20 -0400 Subject: [PATCH] Support bounded version ranges in mod dependencies Adds NuGet interval notation ("[1.0,2.0)", "(1.0,]", etc.) and npm-style space-separated AND (">=1.0 <2.0") to SemanticVersion.SastifiesDependency. The dependency string is normalized into a list of single-operator constraints that are ANDed together. Existing dependency strings take a one-element fast path through the unchanged per-operator logic (extracted into SatisfiesSingleOp), so behavior is identical for every previously-supported form. --- Knossos.NET/Classes/SemanticVersion.cs | 216 ++++++++++++++---- Knossos.NET/Models/Mod.cs | 5 + .../Templates/DevModPkgMgrViewModel.cs | 69 ++++-- .../ViewModels/Templates/Tasks/InstallMod.cs | 2 +- 4 files changed, 229 insertions(+), 63 deletions(-) diff --git a/Knossos.NET/Classes/SemanticVersion.cs b/Knossos.NET/Classes/SemanticVersion.cs index e90a498b..4b99d47e 100644 --- a/Knossos.NET/Classes/SemanticVersion.cs +++ b/Knossos.NET/Classes/SemanticVersion.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; namespace Knossos.NET.Classes { @@ -258,7 +260,7 @@ private static string PreReleaseSeparateNumbers(string preReleaseString) /// /// Compares a semantic version string to the version string in the mod dependency to see if it sastifies the requirement. - /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older + /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older, Version: "[4.6.1,5.0.0)" -> NuGet interval (any combination of [/]/(/) brackets), Version: ">=4.6.1 <5.0.0" -> space-separated AND /// /// /// @@ -276,7 +278,7 @@ public static bool SastifiesDependency(string? dependencyVersion, string? versio */ /// /// Compares a semantic version to the version string in the mod dependency to see if it sastifies the requirement. - /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older + /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older, Version: "[4.6.1,5.0.0)" -> NuGet interval (any combination of [/]/(/) brackets), Version: ">=4.6.1 <5.0.0" -> space-separated AND /// /// /// @@ -291,73 +293,191 @@ public static bool SastifiesDependency(string? dependencyVersion, SemanticVersio return true; } - if (dependencyVersion.Contains("~")) + var parts = NormalizeToSingleOps(dependencyVersion.Trim()); + return parts.Count > 0 && parts.All(p => SatisfiesSingleOp(p, version)); + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "SemanticVersion.SastifiesDependency()", ex); + return false; + } + } + + /// + /// True if the constraint cannot be represented as a single-operator + version (e.g. NuGet interval + /// "[1.0,2.0)" or space-separated AND ">=1.0 <2.0"). Used by callers that strip operators or by UI + /// pickers that can only display a single operator. + /// + public static bool IsComplexConstraint(string? constraint) + { + if (string.IsNullOrWhiteSpace(constraint)) + return false; + var s = constraint.Trim(); + if (s.Length == 0) + return false; + if (s[0] == '[' || s[0] == '(') + return true; + if (s.Any(char.IsWhiteSpace)) + return true; + return false; + } + + /// + /// Returns the bare lower-bound version string from any supported constraint form, or null when the + /// constraint has no lower bound (e.g. "<2.0.0" or "(,2.0]"). Null-safe and exception-safe. + /// + public static string? GetLowerBound(string? constraint) + { + if (string.IsNullOrWhiteSpace(constraint)) + return null; + try + { + var parts = NormalizeToSingleOps(constraint.Trim()); + foreach (var p in parts) { - var versionDep = new SemanticVersion(dependencyVersion.Replace("~", "")); - /* major and minor has to math, revision needs to be equal or superior*/ - if (version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) - { - if (Compare(version, versionDep) >= 0) - { - return true; - } - } - return false; + if (p.StartsWith(">=")) + return p.Substring(2).Trim(); + if (p.StartsWith("~")) + return p.Substring(1).Trim(); + if (p.StartsWith(">")) + return p.Substring(1).Trim(); + if (p.StartsWith("<")) + continue; + return p.Trim(); } + return null; + } + catch + { + return null; + } + } - if (dependencyVersion.Contains(">=")) - { - var versionDep = new SemanticVersion(dependencyVersion.Replace(">=", "")); - /* major minor and revision needs to be equal or superior*/ - if (version.major >= versionDep.major || version.major == versionDep.major && version.minor >= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) - { - if (Compare(version, versionDep) >= 0) - { - return true; - } - } + /// + /// Splits a dependency version string into a list of single-operator constraints to be ANDed together. + /// Recognizes NuGet interval notation, npm hyphen ranges, and npm space-separated ranges; otherwise + /// returns the input unchanged as a one-element list (the existing single-operator path). + /// + private static List NormalizeToSingleOps(string input) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(input)) + return result; + + //NuGet interval notation: [X,Y], (X,Y), [X,Y), (X,Y], [X,), (X,], (,Y], (,Y), [X] + if (input[0] == '[' || input[0] == '(') + { + var openBracket = input[0]; + var closeBracket = input[input.Length - 1]; + if (closeBracket != ']' && closeBracket != ')') + throw new Exception("Invalid NuGet interval, missing closing bracket: " + input); + + var inner = input.Substring(1, input.Length - 2); + var commaIdx = inner.IndexOf(','); - return false; + if (commaIdx < 0) + { + //No comma -> [X] form (exact match). Both brackets must be square. + if (openBracket != '[' || closeBracket != ']') + throw new Exception("NuGet exact form requires square brackets: " + input); + var v = inner.Trim(); + if (v.Length == 0) + throw new Exception("NuGet exact form requires a version: " + input); + result.Add(v); + return result; } - if (dependencyVersion.Contains("<=")) + var lowStr = inner.Substring(0, commaIdx).Trim(); + var highStr = inner.Substring(commaIdx + 1).Trim(); + + if (lowStr.Length > 0) + result.Add((openBracket == '[' ? ">=" : ">") + lowStr); + if (highStr.Length > 0) + result.Add((closeBracket == ']' ? "<=" : "<") + highStr); + + return result; + } + + //npm space-separated AND. Also catches single-op-with-internal-space like ">= 4.6.1". + if (input.Any(char.IsWhiteSpace)) + { + var matches = Regex.Matches(input, @"(>=|<=|>|<|~)?\s*\d+(?:\.\d+){0,2}(?:-\S+)?"); + if (matches.Count == 0) + throw new Exception("Could not parse range: " + input); + foreach (Match m in matches) + result.Add(Regex.Replace(m.Value, @"\s+", "")); + return result; + } + + //Single-operator form, no parsing needed. + result.Add(input); + return result; + } + + /// + /// Evaluates a single-operator constraint string (e.g. ">=4.6.1", "~4.6.1", "4.6.1") against a candidate + /// version. This is the original per-operator logic, extracted unchanged from SastifiesDependency. + /// + private static bool SatisfiesSingleOp(string singleOp, SemanticVersion version) + { + if (singleOp.Contains("~")) + { + var versionDep = new SemanticVersion(singleOp.Replace("~", "")); + /* major and minor has to math, revision needs to be equal or superior*/ + if (version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) { - var versionDep = new SemanticVersion(dependencyVersion.Replace("<=", "")); - /* major minor and revision needs to be equal or inferior*/ - if (version.major <= versionDep.major || version.major == versionDep.major && version.minor <= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision <= versionDep.revision) + if (Compare(version, versionDep) >= 0) { - if (Compare(version, versionDep) <= 0) - { - return true; - } + return true; } - - return false; } + return false; + } - if (dependencyVersion.Contains(">")) + if (singleOp.Contains(">=")) + { + var versionDep = new SemanticVersion(singleOp.Replace(">=", "")); + /* major minor and revision needs to be equal or superior*/ + if (version.major >= versionDep.major || version.major == versionDep.major && version.minor >= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) { - var versionDep = new SemanticVersion(dependencyVersion.Replace(">", "")); - return Compare(version, versionDep) > 0; + if (Compare(version, versionDep) >= 0) + { + return true; + } } - if (dependencyVersion.Contains("<")) - { - var versionDep = new SemanticVersion(dependencyVersion.Replace("<", "")); - return Compare(version, versionDep) < 0; - } + return false; + } - if (Compare(version, new SemanticVersion(dependencyVersion)) == 0) + if (singleOp.Contains("<=")) + { + var versionDep = new SemanticVersion(singleOp.Replace("<=", "")); + /* major minor and revision needs to be equal or inferior*/ + if (version.major <= versionDep.major || version.major == versionDep.major && version.minor <= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision <= versionDep.revision) { - return true; + if (Compare(version, versionDep) <= 0) + { + return true; + } } return false; - }catch (Exception ex) + } + + if (singleOp.Contains(">")) { - Log.Add(Log.LogSeverity.Error, "SemanticVersion.SastifiesDependency()", ex); - return false; + var versionDep = new SemanticVersion(singleOp.Replace(">", "")); + return Compare(version, versionDep) > 0; } + + if (singleOp.Contains("<")) + { + var versionDep = new SemanticVersion(singleOp.Replace("<", "")); + return Compare(version, versionDep) < 0; + } + + return Compare(version, new SemanticVersion(singleOp)) == 0; } public static bool operator >(SemanticVersion a, SemanticVersion b) diff --git a/Knossos.NET/Models/Mod.cs b/Knossos.NET/Models/Mod.cs index abb6d9c2..7e77449d 100644 --- a/Knossos.NET/Models/Mod.cs +++ b/Knossos.NET/Models/Mod.cs @@ -415,6 +415,11 @@ private List FilterDependencies(List unFilteredDep { temp.Remove(d); } + else if (SemanticVersion.IsComplexConstraint(d.version)) + { + //Range syntax (e.g. "[1.0,2.0)" or ">=1.0 <2.0") cannot be safely stripped + //to a bare version for the dedup comparisons below; leave in temp as-is. + } else { if (d.version.Contains(">=")) diff --git a/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs b/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs index 23203883..23b820a2 100644 --- a/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs +++ b/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; +using Knossos.NET.Classes; using Knossos.NET.Models; using Knossos.NET.Views; using System; @@ -26,6 +27,11 @@ public partial class EditorDependencyItem : ObservableObject [ObservableProperty] internal bool displayPackages = false; + //True when the dep was loaded with a range/complex constraint that the dropdowns can't represent. + //Cleared on any user interaction with the mod, version, or operator dropdowns. While set, + //GetDependency() returns the original Dependency unchanged so the constraint round-trips intact. + private bool preserveOriginalConstraint = false; + internal int versionSelectedIndex = 0; internal int VersionSelectedIndex { @@ -38,6 +44,7 @@ internal int VersionSelectedIndex if(versionSelectedIndex != value) { SetProperty(ref versionSelectedIndex, value); + preserveOriginalConstraint = false; FillPackages(); } } @@ -46,6 +53,19 @@ internal int VersionSelectedIndex [ObservableProperty] internal int versionTypeIndex = 0; + partial void OnVersionTypeIndexChanged(int value) + { + if (preserveOriginalConstraint) + { + //User edited the operator while a complex constraint was preserved — exit preserve mode + //and rebuild the version dropdown so the placeholder is gone and a real version is selected. + preserveOriginalConstraint = false; + VersionItems.Clear(); + FillAllVersions(); + VersionSelectedIndex = 1; + } + } + internal int modSelectedIndex = 0; internal int ModSelectedIndex { @@ -58,6 +78,7 @@ internal int ModSelectedIndex if (modSelectedIndex != value) { SetProperty(ref modSelectedIndex, value); + preserveOriginalConstraint = false; VersionItems.Clear(); FillAllVersions(); VersionSelectedIndex = 1; @@ -90,25 +111,39 @@ public EditorDependencyItem(ModDependency dep, EditorModPackageItem pkgItem, str FillAllVersions(); - versionTypeIndex = OperatorTypeIndexFromVersion(dep.version); - - var bareVersion = dep.version != null ? StripVersionOperators(dep.version) : null; - var currentVersion = VersionItems.FirstOrDefault(x => x.Content != null && bareVersion != null && x.Content.ToString() == bareVersion); - if (currentVersion != null) - { - versionSelectedIndex = VersionItems.IndexOf(currentVersion); - } - else if (!string.IsNullOrEmpty(bareVersion)) + if (SemanticVersion.IsComplexConstraint(dep.version)) { - //Requested version isn't installed — surface it as its own entry so the UI matches the JSON. - var itemVer = new ComboBoxItem(); - itemVer.Content = bareVersion; - VersionItems.Add(itemVer); + //Range syntax cannot be expressed via the operator+version dropdowns; show the original + //string in a placeholder item and enter preserve mode so GetDependency() round-trips + //the dep unchanged unless the user edits one of the dropdowns. + preserveOriginalConstraint = true; + var complexItem = new ComboBoxItem { Content = dep.version }; + VersionItems.Add(complexItem); versionSelectedIndex = VersionItems.Count - 1; + versionTypeIndex = 0; } else { - VersionSelectedIndex = 0; + versionTypeIndex = OperatorTypeIndexFromVersion(dep.version); + + var bareVersion = dep.version != null ? StripVersionOperators(dep.version) : null; + var currentVersion = VersionItems.FirstOrDefault(x => x.Content != null && bareVersion != null && x.Content.ToString() == bareVersion); + if (currentVersion != null) + { + versionSelectedIndex = VersionItems.IndexOf(currentVersion); + } + else if (!string.IsNullOrEmpty(bareVersion)) + { + //Requested version isn't installed — surface it as its own entry so the UI matches the JSON. + var itemVer = new ComboBoxItem(); + itemVer.Content = bareVersion; + VersionItems.Add(itemVer); + versionSelectedIndex = VersionItems.Count - 1; + } + else + { + VersionSelectedIndex = 0; + } } FillPackages(); @@ -291,6 +326,12 @@ internal void ReloadDependency() return Dependency; } + //If the version is a preserved complex constraint and the user hasn't touched anything, round-trip it unchanged + if (preserveOriginalConstraint) + { + return Dependency; + } + var depId = ModItems[ModSelectedIndex].Tag as string; var depVersion = VersionItems[VersionSelectedIndex].Content as string; diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs index f74c6703..5d2f1196 100644 --- a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs +++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs @@ -70,7 +70,7 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource try { var fso = mod.GetDependency("FSO"); - if (fso != null && (fso.version == null || SemanticVersion.Compare(fso.version.Replace(">=", "").Replace("<=", "").Replace(">", "").Replace("<", "").Replace("~", "").Trim(), VPCompression.MinimumFSOVersion) > 0)) + if (fso != null && (fso.version == null || SemanticVersion.Compare(SemanticVersion.GetLowerBound(fso.version) ?? "0.0.0", VPCompression.MinimumFSOVersion) > 0)) compressMod = true; } catch (Exception ex)