using System;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Utilities.AssetPathUtilities;
using StardewValley;
using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
namespace StardewModdingAPI.Framework.Content
{
/// An asset name that can be loaded through the content pipeline.
internal class AssetName : IAssetName
{
/*********
** Fields
*********/
/// A lowercase version of used for consistent hash codes and equality checks.
private readonly string ComparableName;
/*********
** Accessors
*********/
///
public string Name { get; }
///
public string BaseName { get; }
///
public string? LocaleCode { get; }
///
public LocalizedContentManager.LanguageCode? LanguageCode { get; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The base asset name without the locale code.
/// The locale code specified in the , if it's a valid code recognized by the game content.
/// The language code matching the , if applicable.
public AssetName(string baseName, string? localeCode, LocalizedContentManager.LanguageCode? languageCode)
{
// validate
if (string.IsNullOrWhiteSpace(baseName))
throw new ArgumentException("The asset name can't be null or empty.", nameof(baseName));
if (string.IsNullOrWhiteSpace(localeCode))
localeCode = null;
// set base values
this.BaseName = PathUtilities.NormalizeAssetName(baseName);
this.LocaleCode = localeCode;
this.LanguageCode = languageCode;
// set derived values
this.Name = localeCode != null
? string.Concat(this.BaseName, '.', this.LocaleCode)
: this.BaseName;
this.ComparableName = this.Name.ToLowerInvariant();
}
/// Parse a raw asset name into an instance.
/// The raw asset name to parse.
/// Get the language code for a given locale, if it's valid.
/// The is null or empty.
public static AssetName Parse(string rawName, Func parseLocale)
{
if (string.IsNullOrWhiteSpace(rawName))
throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName));
string baseName = rawName;
string? localeCode = null;
LocalizedContentManager.LanguageCode? languageCode = null;
int lastPeriodIndex = rawName.LastIndexOf('.');
if (lastPeriodIndex > 0 && rawName.Length > lastPeriodIndex + 1)
{
string possibleLocaleCode = rawName[(lastPeriodIndex + 1)..];
LocalizedContentManager.LanguageCode? possibleLanguageCode = parseLocale(possibleLocaleCode);
if (possibleLanguageCode != null)
{
baseName = rawName[..lastPeriodIndex];
localeCode = possibleLocaleCode;
languageCode = possibleLanguageCode;
}
}
return new AssetName(baseName, localeCode, languageCode);
}
///
public bool IsEquivalentTo(string? assetName, bool useBaseName = false)
{
// empty asset key is never equivalent
if (string.IsNullOrWhiteSpace(assetName))
return false;
AssetNamePartEnumerator curParts = new(useBaseName ? this.BaseName : this.Name);
AssetNamePartEnumerator otherParts = new(assetName.AsSpan().Trim());
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;
}
}
///
public bool IsEquivalentTo(IAssetName? assetName, bool useBaseName = false)
{
if (useBaseName)
return this.BaseName.Equals(assetName?.BaseName, StringComparison.OrdinalIgnoreCase);
if (assetName is AssetName impl)
return this.ComparableName == impl.ComparableName;
return this.Name.Equals(assetName?.Name, StringComparison.OrdinalIgnoreCase);
}
///
public bool StartsWith(string? prefix, bool allowPartialWord = true, bool allowSubfolder = true)
{
// asset keys never start with null
if (prefix is null)
return false;
// 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 AssetPathYielder will trim them
if (pathSeparators.Contains(trimmedPrefix[0]))
return false;
// compare segments
AssetNamePartEnumerator curParts = new(this.Name);
AssetNamePartEnumerator prefixParts = new(trimmedPrefix);
while (true)
{
bool curHasMore = curParts.MoveNext();
bool prefixHasMore = prefixParts.MoveNext();
// 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;
}
// 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);
}
}
}
///
public bool IsDirectlyUnderPath(string? assetFolder)
{
if (assetFolder is null)
return false;
return this.StartsWith(assetFolder + ToolkitPathUtilities.PreferredPathSeparator, allowPartialWord: false, allowSubfolder: false);
}
///
IAssetName IAssetName.GetBaseAssetName()
{
return this.LocaleCode == null
? this
: new AssetName(this.BaseName, null, null);
}
///
public bool Equals(IAssetName? other)
{
return other switch
{
null => false,
AssetName otherImpl => this.ComparableName == otherImpl.ComparableName,
_ => StringComparer.OrdinalIgnoreCase.Equals(this.Name, other.Name)
};
}
///
public override int GetHashCode()
{
return this.ComparableName.GetHashCode();
}
///
public override string ToString()
{
return this.Name;
}
}
}