Merge pull request #878 from atravita-mods/develop
Rewrite asset name comparison to stop at the first mismatch
This commit is contained in:
commit
2a8cb8c636
|
@ -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
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue