diff options
Diffstat (limited to 'src')
27 files changed, 576 insertions, 196 deletions
diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 88412d92..3508a6db 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -7,7 +7,11 @@ using System.Reflection; using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using Newtonsoft.Json; using StardewModdingAPI.ModBuildConfig.Framework; +using StardewModdingAPI.Toolkit.Framework; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig @@ -75,9 +79,41 @@ namespace StardewModdingAPI.ModBuildConfig this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}"); } + // skip if nothing to do + // (This must be checked before the manifest validation, to allow cases like unit test projects.) if (!this.EnableModDeploy && !this.EnableModZip) - return true; // nothing to do + return true; + + // validate the manifest file + IManifest manifest; + { + try + { + string manifestPath = Path.Combine(this.ProjectDir, "manifest.json"); + if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) + { + this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist."); + return false; + } + manifest = rawManifest; + } + catch (JsonReaderException ex) + { + // log the inner exception, otherwise the message will be generic + Exception exToShow = ex.InnerException ?? ex; + this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}"); + return false; + } + + // validate manifest fields + if (!ManifestValidator.TryValidateFields(manifest, out string error)) + { + this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}"); + return false; + } + } + // deploy files try { // parse extra DLLs to bundle @@ -101,7 +137,7 @@ namespace StardewModdingAPI.ModBuildConfig // create release zip if (this.EnableModZip) { - string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {package.GetManifestVersion()}.zip"); + string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); string zipPath = Path.Combine(this.ModZipPath, zipName); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index 80955f67..00f3f439 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework @@ -113,16 +111,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework return new Dictionary<string, FileInfo>(this.Files, StringComparer.OrdinalIgnoreCase); } - /// <summary>Get a semantic version from the mod manifest.</summary> - /// <exception cref="UserErrorException">The manifest is missing or invalid.</exception> - public string GetManifestVersion() - { - if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest)) - throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor - - return manifest.Version.ToString(); - } - /********* ** Private methods diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index d1296dbe..ddb0e20d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.17.2", + "Version": "3.18.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.17.2" + "MinimumApiVersion": "3.18.0" } diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index c3757e8f..eeea1d28 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.17.2", + "Version": "3.18.0", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.17.2" + "MinimumApiVersion": "3.18.0" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 78821a3e..0acf066d 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.17.2", + "Version": "3.18.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.17.2" + "MinimumApiVersion": "3.18.0" } diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs index 655e9bae..2d546ec7 100644 --- a/src/SMAPI.Tests/Core/AssetNameTests.cs +++ b/src/SMAPI.Tests/Core/AssetNameTests.cs @@ -151,6 +151,12 @@ namespace SMAPI.Tests.Core // with locale codes [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + + // prefix ends with path separator + [TestCase("Data/Events/Boop", "Data/Events/", ExpectedResult = true)] + [TestCase("Data/Events/Boop", "Data/Events\\", ExpectedResult = true)] + [TestCase("Data/Events", "Data/Events/", ExpectedResult = false)] + [TestCase("Data/Events", "Data/Events\\", ExpectedResult = false)] public bool StartsWith_SimpleCases(string mainAssetName, string prefix) { // arrange @@ -243,6 +249,41 @@ namespace SMAPI.Tests.Core return result; } + [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", true, ExpectedResult = true)] + [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", false, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] + public bool StartsWith_PartialMatchInPathSegment(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value + return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + } + + // The enumerator strips the trailing path separator, so each of these cases has to be handled on each branch. + [TestCase("Mods/SomeMod", "Mods/", false, ExpectedResult = true)] + [TestCase("Mods/SomeMod", "Mods", false, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas/", false, ExpectedResult = false)] + [TestCase("Mods/Jasper/Data", "Mods/Jas", false, ExpectedResult = false)] + [TestCase("Mods/Jas", "Mods/Jas/", false, ExpectedResult = false)] + [TestCase("Mods/Jas", "Mods/Jas", false, ExpectedResult = true)] + public bool StartsWith_PrefixHasSeparator(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value + return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + } + /**** ** GetHashCode diff --git a/src/SMAPI.Tests/Core/AssumptionTests.cs b/src/SMAPI.Tests/Core/AssumptionTests.cs new file mode 100644 index 00000000..efc9da3f --- /dev/null +++ b/src/SMAPI.Tests/Core/AssumptionTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using FluentAssertions.Execution; +using NUnit.Framework; +using StardewModdingAPI.Framework.Models; + +namespace SMAPI.Tests.Core +{ + /// <summary>Unit tests which validate assumptions about .NET used in the SMAPI implementation.</summary> + [TestFixture] + internal class AssumptionTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that {nameof(HashSet<string>)} maintains insertion order when no elements are removed. If this fails, we'll need to change the implementation for the {nameof(SConfig.ModsToLoadEarly)} and {nameof(SConfig.ModsToLoadLate)} options.")] + [TestCase("construct from array")] + [TestCase("add incrementally")] + public void HashSet_MaintainsInsertionOrderWhenNoElementsAreRemoved(string populateMethod) + { + // arrange + string[] inserted = Enumerable.Range(0, 1000) + .Select(_ => Guid.NewGuid().ToString("N")) + .ToArray(); + + // act + HashSet<string> set; + switch (populateMethod) + { + case "construct from array": + set = new(inserted, StringComparer.OrdinalIgnoreCase); + break; + + case "add incrementally": + set = new(StringComparer.OrdinalIgnoreCase); + foreach (string value in inserted) + set.Add(value); + break; + + default: + throw new AssertionFailedException($"Unknown populate method '{populateMethod}'."); + } + + // assert + string[] actualOrder = set.ToArray(); + actualOrder.Should().HaveCount(inserted.Length); + for (int i = 0; i < inserted.Length; i++) + { + string expected = inserted[i]; + string actual = actualOrder[i]; + + if (actual != expected) + throw new AssertionFailedException($"The hash set differed at index {i}: expected {expected}, but found {actual} instead."); + } + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/ManifestValidator.cs b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs new file mode 100644 index 00000000..461dc325 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Toolkit.Framework +{ + /// <summary>Validates manifest fields.</summary> + public static class ManifestValidator + { + /// <summary>Validate a manifest's fields.</summary> + /// <param name="manifest">The manifest to validate.</param> + /// <param name="error">The error message indicating why validation failed, if applicable.</param> + /// <returns>Returns whether all manifest fields validated successfully.</returns> + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method that ensures those annotations are respected.")] + public static bool TryValidateFields(IManifest manifest, out string error) + { + // + // Note: SMAPI assumes that it can grammatically append the returned sentence in the + // form "failed loading <mod> because its <error>". Any errors returned should be valid + // in that format, unless the SMAPI call is adjusted accordingly. + // + + bool hasDll = !string.IsNullOrWhiteSpace(manifest.EntryDll); + bool isContentPack = manifest.ContentPackFor != null; + + // validate use of EntryDll vs ContentPackFor fields + if (hasDll == isContentPack) + { + error = hasDll + ? $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive." + : $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."; + return false; + } + + // validate EntryDll/ContentPackFor format + if (hasDll) + { + if (manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) + { + error = $"manifest has invalid filename '{manifest.EntryDll}' for the {nameof(IManifest.EntryDll)} field."; + return false; + } + } + else + { + if (string.IsNullOrWhiteSpace(manifest.ContentPackFor!.UniqueID)) + { + error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."; + return false; + } + } + + // validate required fields + { + List<string> missingFields = new List<string>(3); + + if (string.IsNullOrWhiteSpace(manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (manifest.Version == null || manifest.Version.ToString() == "0.0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) + { + error = $"manifest is missing required fields ({string.Join(", ", missingFields)})."; + return false; + } + } + + // validate ID format + if (!PathUtilities.IsSlug(manifest.UniqueID)) + { + error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + return false; + } + + // validate dependency format + foreach (IManifestDependency? dependency in manifest.Dependencies) + { + if (dependency == null) + { + error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}."; + return false; + } + + if (string.IsNullOrWhiteSpace(dependency.UniqueID)) + { + error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."; + return false; + } + + if (!PathUtilities.IsSlug(dependency.UniqueID)) + { + error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + return false; + } + } + + error = ""; + return true; + } + } +} diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 5e0dedf3..c39e612b 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -108,6 +108,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } } + // detect split-screen mode + if (message.ScreenId != 0) + log.IsSplitScreen = true; + // collect SMAPI metadata if (message.Mod == "SMAPI") { diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index cda0f653..2a2e5f5c 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -22,6 +22,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models /// <summary>The raw log text.</summary> public string? RawText { get; set; } + /// <summary>Whether there are messages from multiple screens in the log.</summary> + public bool IsSplitScreen { get; set; } + /**** ** Log data ****/ diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 28127903..c1251c21 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -47,7 +47,7 @@ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js"></script> - <script src="~/Content/js/log-parser.js?r=20220409"></script> + <script src="~/Content/js/log-parser.js"></script> <script id="serializedData" type="application/json"> @if (!Model.ShowRaw) @@ -86,7 +86,8 @@ showMods: @this.ForJson(log?.Mods.Where(p => p.Loaded && !p.IsContentPack).Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)), showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), showLevels: @this.ForJson(defaultFilters), - enableFilters: @this.ForJson(!Model.ShowRaw) + enableFilters: @this.ForJson(!Model.ShowRaw), + isSplitScreen: @this.ForJson(log?.IsSplitScreen ?? false) } ); @@ -494,7 +495,6 @@ else if (log?.IsValid == true) <log-line v-for="msg in visibleMessages" v-bind:key="msg.id" - v-bind:showScreenId="showScreenId" v-bind:message="msg" v-bind:highlight="shouldHighlight" /> diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index fccd00be..324218bb 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -424,10 +424,6 @@ smapi.logParser = function (state) { Vue.component("log-line", { functional: true, props: { - showScreenId: { - type: Boolean, - required: true - }, message: { type: Object, required: true @@ -456,7 +452,7 @@ smapi.logParser = function (state) { "td", { attrs: { - colspan: context.props.showScreenId ? 4 : 3 + colspan: state.isSplitScreen ? 4 : 3 } }, "" @@ -541,7 +537,7 @@ smapi.logParser = function (state) { }, [ createElement("td", message.Time), - context.props.showScreenId ? createElement("td", message.ScreenId) : null, + state.isSplitScreen ? createElement("td", { attrs: { title: (message.ScreenId == 0 ? "main screen" : "screen #" + (message.ScreenId + 1)) + " in split-screen mode" } }, `🖵${message.ScreenId + 1}`) : null, createElement("td", level.toUpperCase()), createElement( "td", @@ -588,9 +584,6 @@ smapi.logParser = function (state) { anyModsShown: function () { return stats.modsShown > 0; }, - showScreenId: function () { - return this.data.screenIds.length > 1; - }, // Maybe not strictly necessary, but the Vue template is being // weird about accessing data entries on the app rather than diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 77900ca6..02e75e05 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -52,7 +52,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.17.2"; + internal static string RawApiVersion = "3.18.0"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -71,7 +71,7 @@ namespace StardewModdingAPI public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The maximum supported version of Stardew Valley, if any.</summary> - public static ISemanticVersion? MaximumGameVersion { get; } = null; + public static ISemanticVersion? MaximumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The target game platform.</summary> public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; @@ -90,7 +90,7 @@ namespace StardewModdingAPI source: null, nounPhrase: $"{nameof(Constants)}.{nameof(Constants.ExecutionPath)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return Constants.GamePath; diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index af000300..52ef02e6 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs index 148354a1..8355f9ec 100644 --- a/src/SMAPI/Framework/Content/AssetName.cs +++ b/src/SMAPI/Framework/Content/AssetName.cs @@ -1,6 +1,8 @@ using System; using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities.AssetPathUtilities; using StardewValley; +using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; namespace StardewModdingAPI.Framework.Content { @@ -94,10 +96,26 @@ namespace StardewModdingAPI.Framework.Content if (string.IsNullOrWhiteSpace(assetName)) return false; - assetName = PathUtilities.NormalizeAssetName(assetName); + AssetNamePartEnumerator curParts = new(useBaseName ? this.BaseName : this.Name); + AssetNamePartEnumerator otherParts = new(assetName.AsSpan().Trim()); - string compareTo = useBaseName ? this.BaseName : this.Name; - return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase); + while (true) + { + bool curHasMore = curParts.MoveNext(); + bool otherHasMore = otherParts.MoveNext(); + + // mismatch: lengths differ + if (otherHasMore != curHasMore) + return false; + + // match: both reached the end without a mismatch + if (!curHasMore) + return true; + + // mismatch: current segment is different + if (!curParts.Current.Equals(otherParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + } } /// <inheritdoc /> @@ -119,42 +137,76 @@ namespace StardewModdingAPI.Framework.Content if (prefix is null) return false; - string rawTrimmed = prefix.Trim(); + // get initial values + ReadOnlySpan<char> trimmedPrefix = prefix.AsSpan().Trim(); + if (trimmedPrefix.Length == 0) + return true; + ReadOnlySpan<char> pathSeparators = new(ToolkitPathUtilities.PossiblePathSeparators); // just to simplify calling other span APIs - // asset keys can't have a leading slash, but NormalizeAssetName will trim them - if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\')) + // asset keys can't have a leading slash, but AssetPathYielder will trim them + if (pathSeparators.Contains(trimmedPrefix[0])) return false; - // normalize prefix + // compare segments + AssetNamePartEnumerator curParts = new(this.Name); + AssetNamePartEnumerator prefixParts = new(trimmedPrefix); + while (true) { - string normalized = PathUtilities.NormalizeAssetName(prefix); + bool curHasMore = curParts.MoveNext(); + bool prefixHasMore = prefixParts.MoveNext(); - // keep trailing slash - if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\')) - normalized += PathUtilities.PreferredAssetSeparator; + // reached end for one side + if (prefixHasMore != curHasMore) + { + // mismatch: prefix is longer + if (prefixHasMore) + return false; + + // match: every segment in the prefix matched and subfolders are allowed (e.g. prefix 'Data/Events' with target 'Data/Events/Beach') + if (allowSubfolder) + return true; + + // Special case: the prefix ends with a path separator, but subfolders aren't allowed. This case + // matches if there's no further path separator in the asset name *after* the current separator. + // For example, the prefix 'A/B/' matches 'A/B/C' but not 'A/B/C/D'. + return pathSeparators.Contains(trimmedPrefix[^1]) && curParts.Remainder.Length == 0; + } - prefix = normalized; - } + // previous segments matched exactly and both reached the end + // match if prefix doesn't end with '/' (which should only match subfolders) + if (!prefixHasMore) + return !pathSeparators.Contains(trimmedPrefix[^1]); - // compare - if (prefix.Length == 0) - return true; + // compare segment + if (curParts.Current.Length == prefixParts.Current.Length) + { + // mismatch: segments aren't equivalent + if (!curParts.Current.Equals(prefixParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + } + else + { + // mismatch: prefix has more beyond this, and this segment isn't an exact match + if (prefixParts.Remainder.Length != 0) + return false; - return - this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - && ( - allowPartialWord - || this.Name.Length == prefix.Length - || !char.IsLetterOrDigit(prefix[^1]) // last character in suffix is word separator - || !char.IsLetterOrDigit(this.Name[prefix.Length]) // or first character after it is - ) - && ( - allowSubfolder - || this.Name.Length == prefix.Length - || !this.Name[prefix.Length..].Contains(PathUtilities.PreferredAssetSeparator) - ); - } + // mismatch: cur segment doesn't start with prefix + if (!curParts.Current.StartsWith(prefixParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + + // mismatch: something like "Maps/" would need an exact match + if (pathSeparators.Contains(trimmedPrefix[^1])) + return false; |
