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; } } }