Merge pull request #878 from atravita-mods/develop

Rewrite asset name comparison to stop at the first mismatch
This commit is contained in:
Jesse Plamondon-Willard 2022-11-10 21:52:00 -05:00 committed by GitHub
commit 2a8cb8c636
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 170 additions and 32 deletions

View File

@ -151,6 +151,12 @@ namespace SMAPI.Tests.Core
// with locale codes // with locale codes
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] [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) public bool StartsWith_SimpleCases(string mainAssetName, string prefix)
{ {
// arrange // arrange
@ -243,6 +249,22 @@ namespace SMAPI.Tests.Core
return result; 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 ** GetHashCode

View File

@ -1,6 +1,8 @@
using System; using System;
using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Utilities.AssetPathUtilities;
using StardewValley; using StardewValley;
using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
namespace StardewModdingAPI.Framework.Content namespace StardewModdingAPI.Framework.Content
{ {
@ -94,10 +96,26 @@ namespace StardewModdingAPI.Framework.Content
if (string.IsNullOrWhiteSpace(assetName)) if (string.IsNullOrWhiteSpace(assetName))
return false; 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; while (true)
return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase); {
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 /> /// <inheritdoc />
@ -119,50 +137,78 @@ namespace StardewModdingAPI.Framework.Content
if (prefix is null) if (prefix is null)
return false; 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 // asset keys can't have a leading slash, but AssetPathYielder will trim them
if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\')) if (pathSeparators.Contains(trimmedPrefix[0]))
return false; 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 // reached end for one side
if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\')) if (prefixHasMore != curHasMore)
normalized += PathUtilities.PreferredAssetSeparator; {
// 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)
);
} }
/// <inheritdoc /> /// <inheritdoc />
public bool IsDirectlyUnderPath(string? assetFolder) public bool IsDirectlyUnderPath(string? assetFolder)
{ {
if (assetFolder is null) if (assetFolder is null)
return false; return false;
return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false); return this.StartsWith(assetFolder + ToolkitPathUtilities.PreferredPathSeparator, allowPartialWord: false, allowSubfolder: false);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -0,0 +1,70 @@
using System;
using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
namespace StardewModdingAPI.Utilities.AssetPathUtilities
{
/// <summary>Handles enumerating the normalized segments in an asset name.</summary>
internal ref struct AssetNamePartEnumerator
{
/*********
** Fields
*********/
/// <summary>The backing field for <see cref="Remainder"/>.</summary>
private ReadOnlySpan<char> RemainderImpl;
/*********
** Properties
*********/
/// <summary>The remainder of the asset name being enumerated, ignoring segments which have already been yielded.</summary>
public ReadOnlySpan<char> Remainder => this.RemainderImpl;
/// <summary>Get the current segment.</summary>
public ReadOnlySpan<char> Current { get; private set; } = default;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="assetName">The asset name to enumerate.</param>
public AssetNamePartEnumerator(ReadOnlySpan<char> assetName)
{
this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(assetName);
}
/// <summary>Move the enumerator to the next segment.</summary>
/// <returns>Returns true if a new value was found (accessible via <see cref="Current"/>).</returns>
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<char>.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
*********/
/// <summary>Trim path separators at the start of the given path or segment.</summary>
/// <param name="span">The path or segment to trim.</param>
private static ReadOnlySpan<char> TrimLeadingPathSeparators(ReadOnlySpan<char> span)
{
return span.TrimStart(new ReadOnlySpan<char>(ToolkitPathUtilities.PossiblePathSeparators));
}
}
}