diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs
index 655e9bae..fdaa2c01 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,22 @@ 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);
+ }
+
/****
** GetHashCode
diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs
index 148354a1..99968299 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;
+ }
}
///
@@ -119,50 +137,78 @@ namespace StardewModdingAPI.Framework.Content
if (prefix is null)
return false;
- string rawTrimmed = prefix.Trim();
+ // get initial values
+ ReadOnlySpan trimmedPrefix = prefix.AsSpan().Trim();
+ if (trimmedPrefix.Length == 0)
+ return true;
+ ReadOnlySpan 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;
- prefix = normalized;
+ // match if subfolder paths are fine (e.g. prefix 'Data/Events' with target 'Data/Events/Beach')
+ return allowSubfolder;
+ }
+
+ // 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 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;
+
+ // 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;
+
+ // mismatch: partial word match not allowed, and the first or last letter of the suffix isn't a word separator
+ if (!allowPartialWord && char.IsLetterOrDigit(prefixParts.Current[^1]) && char.IsLetterOrDigit(curParts.Current[prefixParts.Current.Length]))
+ return false;
+
+ // possible match
+ return allowSubfolder || (pathSeparators.Contains(trimmedPrefix[^1]) ? curParts.Remainder.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators) < 0 : curParts.Remainder.Length == 0);
+ }
}
-
- // compare
- if (prefix.Length == 0)
- return true;
-
- 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)
- );
}
-
///
public bool IsDirectlyUnderPath(string? assetFolder)
{
if (assetFolder is null)
return false;
- return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false);
+ return this.StartsWith(assetFolder + ToolkitPathUtilities.PreferredPathSeparator, allowPartialWord: false, allowSubfolder: false);
}
///
diff --git a/src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs b/src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs
new file mode 100644
index 00000000..11987ed6
--- /dev/null
+++ b/src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs
@@ -0,0 +1,70 @@
+using System;
+using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
+
+namespace StardewModdingAPI.Utilities.AssetPathUtilities
+{
+ /// Handles enumerating the normalized segments in an asset name.
+ internal ref struct AssetNamePartEnumerator
+ {
+ /*********
+ ** Fields
+ *********/
+ /// The backing field for .
+ private ReadOnlySpan RemainderImpl;
+
+
+ /*********
+ ** Properties
+ *********/
+ /// The remainder of the asset name being enumerated, ignoring segments which have already been yielded.
+ public ReadOnlySpan Remainder => this.RemainderImpl;
+
+ /// Get the current segment.
+ public ReadOnlySpan Current { get; private set; } = default;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// Construct an instance.
+ /// The asset name to enumerate.
+ public AssetNamePartEnumerator(ReadOnlySpan assetName)
+ {
+ this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(assetName);
+ }
+
+ /// Move the enumerator to the next segment.
+ /// Returns true if a new value was found (accessible via ).
+ public bool MoveNext()
+ {
+ if (this.RemainderImpl.Length == 0)
+ return false;
+
+ int index = this.RemainderImpl.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators);
+
+ // no more separator characters found, I'm done.
+ if (index < 0)
+ {
+ this.Current = this.RemainderImpl;
+ this.RemainderImpl = ReadOnlySpan.Empty;
+ return true;
+ }
+
+ // Yield the next separate character bit
+ this.Current = this.RemainderImpl[..index];
+ this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(this.RemainderImpl[(index + 1)..]);
+ return true;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// Trim path separators at the start of the given path or segment.
+ /// The path or segment to trim.
+ private static ReadOnlySpan TrimLeadingPathSeparators(ReadOnlySpan span)
+ {
+ return span.TrimStart(new ReadOnlySpan(ToolkitPathUtilities.PossiblePathSeparators));
+ }
+ }
+}