File: Data\StaticWebAssetPathPattern.cs
Web Access
Project: src\src\sdk\src\StaticWebAssetsSdk\Tasks\Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj (Microsoft.NET.Sdk.StaticWebAssets.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System.Diagnostics;

namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

/// <summary>
/// Controls how token expressions are resolved during path computation.
/// </summary>
public enum TokenResolveMode
{
    /// <summary>No preferences applied — all segments included as-is.</summary>
    None,
    /// <summary>Skip optional non-preferred. Include and resolve pack-only (~) segments. Used for nupkg physical paths.</summary>
    Pack,
    /// <summary>Skip optional non-preferred. Strip pack-only (~) segments entirely. Used for routes, dev manifest, copy-to-output.</summary>
    Serve
}

[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0057:Use range operator", Justification = "Can't use range syntax in full framework")]
#if WASM_TASKS
internal sealed class StaticWebAssetPathPattern : IEquatable<StaticWebAssetPathPattern>
#else
public sealed class StaticWebAssetPathPattern : IEquatable<StaticWebAssetPathPattern>
#endif
{
    private const string PatternStart = "#[";
    private const char PatternEnd = ']';
    private const char PatternOptional = '?';
    private const char PatternPreferred = '!';
    private const char PatternPackOnly = '~';
    private const char PatternValueSeparator = '=';
    private const char PatternParameterStart = '{';
    private const char PatternParameterEnd = '}';

    public StaticWebAssetPathPattern(string path) : this(path.AsMemory()) { }

    public StaticWebAssetPathPattern(ReadOnlyMemory<char> rawPathMemory) => RawPattern = rawPathMemory;

    public StaticWebAssetPathPattern(List<StaticWebAssetPathSegment> segments)
    {
        RawPattern = GetRawPattern(segments);
        Segments = segments;
    }

    public ReadOnlyMemory<char> RawPattern { get; private set; }

    public IList<StaticWebAssetPathSegment> Segments { get; set; } = [];

    // Tokens in static web assets represent a similar concept to tokens within routing. They can be used to identify logical
    // values that need to be replaced by well-known strings. The format for defining a token in static web assets is as follows
    // #[.{tokenName}].
    // # is used to make sure we never interpret any valid file path as a token (since # is not allowed to appear in file systems)
    // [] delimit the token expression.
    // Inside the [] there is a token expression that is represented as an interpolated string where {} delimit the variables and
    // the content inside the name of the value they need to be replaced with.
    // The variables might contain 'embeded' values represented by = after the variable name, for example {tokenName=value} this allows
    // us to preserve the original token information when we define related endpoints that required values from their related assets.
    // The expression inside the `[]` can contain any character that can appear in the file system, for example, to indicate that
    // a fixed prefix needs to be added.
    // An expression can be followed by `?` to indicate that the entire token expression is optional and we don't want to fingerprint
    // the file (this indicates that the asset can logically be referenced with or without the expression.
    // For example file[.{integrity}]?.js will mean, the file can be addressed as file.js (no integrity  suffix) or file.asdfasdf.js where
    // '.asdfasdf' is the integrity suffix.
    // An expression can be followed by `!` to indicate that the entire token expression is optional and that we want to fingerprint the
    // file (this indicates that the asset can logically be referenced with or without the expression, but we want to fingerprint the file) but
    // the file on disk will contain the fingerprint.
    // For example file[.{integrity}]!.js will mean, the file can be addressed as file.js (no integrity  suffix) or file.asdfasdf.js where
    // '.asdfasdf' is the integrity suffix, but the file on disk will be named file.asdfasdf.js.
    // Encoding this logic on the path allows other tasks to make decissions on which route to use based on whether they control the hosting
    // or they require the host to match the file name on disk.
    // The reason we want to plan for this is that we don't have the ability to post process all content from the app (CSS files, JS, etc.)
    // to replace the original paths with the replaced paths. This means some files should be served in their original formats so that they
    // work with the content that we couldn't post process, and with the post processed format, so that they can benefit from fingerprinting
    // and other features. This is why we want to bake into the format itself the information that specifies under which paths the file will
    // be available at runtime so that tasks/tools can operate independently and produce correct results.
    // The current token we support is the 'fingerprint' token, which computes a web friendly version of the hash of the file suitable
    // to be embedded in other contexts.
    // We might include other tokens in the future, like `[{basepath}]` to give a file the ability to have its path be relative to the consuming
    // project base path, etc.
    public static StaticWebAssetPathPattern Parse(ReadOnlyMemory<char> rawPathMemory, string assetIdentity = null)
    {
        var pattern = new StaticWebAssetPathPattern(rawPathMemory);
        var current = rawPathMemory;
        var nextToken = MemoryExtensions.IndexOf(current.Span, PatternStart.AsSpan(), StringComparison.OrdinalIgnoreCase);
        if (nextToken == -1)
        {
            var literalSegment = new StaticWebAssetPathSegment();
            literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current, IsLiteral = true });
            pattern.Segments.Add(literalSegment);
            return pattern;
        }

        if (nextToken > 0)
        {
            var literalSegment = new StaticWebAssetPathSegment();
            literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true });
            pattern.Segments.Add(literalSegment);
        }

        while (nextToken != -1)
        {
            current = current.Slice(nextToken);
            var tokenEnd = MemoryExtensions.IndexOf(current.Span, PatternEnd);
            if (tokenEnd == -1)
            {
                if (assetIdentity != null)
                {
                    // We don't have a closing token, this is likely an error, so throw
                    throw new InvalidOperationException($"Invalid relative path '{rawPathMemory}' for asset '{assetIdentity}'. Missing ']' token.");
                }
                else
                {
                    throw new InvalidOperationException($"Invalid token expression '{rawPathMemory}'. Missing ']' token.");
                }
            }

            var tokenExpression = current.Slice(2, tokenEnd - 2);

            var token = new StaticWebAssetPathSegment();
            AddTokenSegmentParts(tokenExpression, token);
            pattern.Segments.Add(token);

            // Check if the segment is optional (ends with ? or !) or pack-only (ends with ~)
            if (tokenEnd < current.Length - 1 &&
                (current.Span[tokenEnd + 1] == PatternOptional || current.Span[tokenEnd + 1] == PatternPreferred || current.Span[tokenEnd + 1] == PatternPackOnly))
            {
                if (current.Span[tokenEnd + 1] == PatternPackOnly)
                {
                    token.IsOptional = true;
                    token.IsPreferred = false;
                    token.IsPackOnly = true;
                }
                else
                {
                    token.IsOptional = true;
                    if (current.Span[tokenEnd + 1] == PatternPreferred)
                    {
                        token.IsPreferred = true;
                    }
                }
                tokenEnd++;
            }

            current = current.Slice(tokenEnd + 1);
            nextToken = MemoryExtensions.IndexOf(current.Span, PatternStart.AsSpan(), StringComparison.OrdinalIgnoreCase);

            if (nextToken == -1 && current.Length > 0)
            {
                var literalSegment = new StaticWebAssetPathSegment();
                literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current, IsLiteral = true });
                pattern.Segments.Add(literalSegment);
            }
            else if (nextToken > 0)
            {
                var literalSegment = new StaticWebAssetPathSegment();
                literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true });
                pattern.Segments.Add(literalSegment);
            }
        }

        return pattern;
    }

    // Iterate over the token expression and add the parts to the token segment
    // Some examples are '.{fingerprint}', '{fingerprint}.', '{fingerprint}{fingerprint}', {fingerprint}.{fingerprint}
    // The '.' represents sample literal content.
    // The value within the {} represents token variables.
    private static void AddTokenSegmentParts(ReadOnlyMemory<char> tokenExpression, StaticWebAssetPathSegment token)
    {
        var current = tokenExpression;
        var nextToken = MemoryExtensions.IndexOf(current.Span, PatternParameterStart);
        if (nextToken is not (-1) and > 0)
        {
            var literalPart = new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true };
            token.Parts.Add(literalPart);
        }

        while (nextToken != -1)
        {
            current = current.Slice(nextToken);
            var tokenEnd = MemoryExtensions.IndexOf(current.Span, PatternParameterEnd);
            if (tokenEnd == -1)
            {
                throw new InvalidOperationException($"Invalid token expression '{tokenExpression}'. Missing '}}' token.");
            }

            var embeddedValue = MemoryExtensions.IndexOf(current.Span, PatternValueSeparator);
            if (embeddedValue != -1)
            {
                var tokenPart = new StaticWebAssetSegmentPart
                {
                    Name = current.Slice(1, embeddedValue - 1),
                    IsLiteral = false,
                    Value = current.Slice(embeddedValue + 1, tokenEnd - embeddedValue - 1)
                };
                token.Parts.Add(tokenPart);
            }
            else
            {
                var tokenPart = new StaticWebAssetSegmentPart { Name = current.Slice(1, tokenEnd - 1), IsLiteral = false };
                token.Parts.Add(tokenPart);
            }

            current = current.Slice(tokenEnd + 1);
            nextToken = MemoryExtensions.IndexOf(current.Span, PatternParameterStart);
            if (nextToken == -1 && current.Length > 0)
            {
                var literalPart = new StaticWebAssetSegmentPart { Name = current, IsLiteral = true };
                token.Parts.Add(literalPart);
            }
            else if (nextToken > 0)
            {
                var literalPart = new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true };
                token.Parts.Add(literalPart);
            }
        }
    }

    public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdentity = null)
    {
        return Parse(rawPath.AsMemory(), assetIdentity);
    }

    // Replaces the tokens in the pattern with values provided in the expression, by the asset, or global resolvers.
    // Embedded values allow tasks to define the values that should be used when defining endpoints, while preserving the
    // original token information (for example, if its optional or if it should be preferred).
    // Values provided in the expression take precedence over values provided by the asset or global resolvers.
    // Values provided by the asset take precedence over values provided by the global resolvers.
    // Right now the only available value is the fingerprint value.
    // Global values in the future can include user defined tokens, like versions, etc. (For example, dotnet version, blazor web.js version, etc.)
    // The resolveMode parameter controls how optional and pack-only segments are handled:
    // - None: all segments included as-is.
    // - Pack: skip optional non-preferred; include and resolve pack-only (~) segments. Used for nupkg paths.
    // - Serve: skip optional non-preferred; strip pack-only (~) segments entirely. Used for routes, dev manifest, copy-to-output.
#if WASM_TASKS
    internal (string Path, Dictionary<string, string> PatternValues) ReplaceTokens(StaticWebAsset staticWebAsset, StaticWebAssetTokenResolver tokens, TokenResolveMode resolveMode = TokenResolveMode.None)
#else
    public (string Path, Dictionary<string, string> PatternValues) ReplaceTokens(StaticWebAsset staticWebAsset, StaticWebAssetTokenResolver tokens, TokenResolveMode resolveMode = TokenResolveMode.None)
#endif
    {
        var result = new StringBuilder();
        var dictionary = new Dictionary<string, string>();
        foreach (var segment in Segments)
        {
            if (IsLiteralSegment(segment))
            {
                result.Append(segment.Parts[0].Name);
            }
            else
            {
                if (resolveMode != TokenResolveMode.None && segment.IsOptional && !segment.IsPreferred && !segment.IsPackOnly)
                {
                    // Skip optional non-preferred segments (e.g. ?), but never pack-only segments here —
                    // they are included in Pack mode and stripped by the explicit check below in Serve mode.
                    continue;
                }

                if (resolveMode == TokenResolveMode.Serve && segment.IsPackOnly)
                {
                    // Pack-only segments (~) are stripped in serve mode (routes, dev manifest, copy-to-output).
                    continue;
                }

                var tokenNames = segment.GetTokenNames();
                var foundAllValues = true;
                var missingValue = "";
                foreach (var tokenName in tokenNames)
                {
                    var tokenNameString = tokenName.ToString();

                    // Check if any part has an embedded value for this token (e.g., {name=value}).
                    // Embedded values take precedence and don't require the resolver.
                    var hasEmbeddedValue = false;
                    foreach (var part in segment.Parts)
                    {
                        if (!part.IsLiteral && part.Name.Span.SequenceEqual(tokenName.Span) && !part.Value.IsEmpty)
                        {
                            hasEmbeddedValue = true;
                            dictionary[tokenNameString] = part.Value.ToString();
                            break;
                        }
                    }

                    if (hasEmbeddedValue)
                    {
                        continue;
                    }

                    if (!tokens.TryGetValue(staticWebAsset, tokenNameString, out var tokenValue) || string.IsNullOrEmpty(tokenValue))
                    {
                        foundAllValues = false;
                        missingValue = tokenNameString;
                        break;
                    }

                    dictionary[tokenNameString] = tokenValue;
                }

                if (!foundAllValues && !segment.IsOptional)
                {
                    // We are missing a value in the expression for a non-optional segment.
                    throw new InvalidOperationException($"Token '{missingValue}' not provided for '{RawPattern}'.");
                }
                else if (!foundAllValues)
                {
                    // Missing a value on an optional expression, this means we don't append this segment.
                    continue;
                }
                else
                {
                    // We have all the values, so we can replace the tokens in the segment.
                    foreach (var part in segment.Parts)
                    {
                        if (part.IsLiteral)
                        {
                            result.Append(part.Name);
                        }
                        else if (!part.Value.IsEmpty)
                        {
                            // Token was embedded, so add it to the dictionary.
                            dictionary[part.Name.ToString()] = part.Value.ToString();
                            result.Append(part.Value);
                        }
                        else
                        {
                            result.Append(dictionary[part.Name.ToString()]);
                        }
                    }
                }
            }
        }

        return (result.ToString(), dictionary);
    }

    // Extracts more than one pattern from a single pattern expression, creating separate patterns for each possible combination of optional segments.
    // This is what transforms a pattern like 'file[.{fingerprint}]?.js' into two patterns 'file[.{fingerprint}]?.js' and 'file.js', which are then used
    // when we define endpoints.
    // During the build the patterns are not "reduced" into their final form so that we can use the pattern expression through the build to refer to a given
    // endpoint by its pattern expression instead of by its final path.
    public IEnumerable<StaticWebAssetPathPattern> ExpandPatternExpression()
    {
        // We are going to analyze each segment and produce the following:
        // - For literals, we just concatenate
        // - For parameter expressions without '?' we return the parameter expression.
        // - For parameter expressions with '?' we return
        // For example:
        // - asset.css produces a single pattern (asset.css).
        // - other#[.{fingerprint}].js produces a single pattern asset#[.{fingerprint}].js
        // - last#[.{fingerprint}]?.txt produces two patterns last#[.{fingerprint}]?.txt and last.txt
        var hasOptionalSegments = false;
        var hasPackOnlySegments = false;
        foreach (var segment in Segments)
        {
            if (segment.IsOptional)
            {
                hasOptionalSegments = true;
            }
            if (segment.IsPackOnly)
            {
                hasPackOnlySegments = true;
            }
        }

        if (!hasOptionalSegments && !hasPackOnlySegments)
        {
            return [this];
        }
        List<List<StaticWebAssetPathSegment>> expandedPatternSegments = [];

        for (var i = 0; i < Segments.Count; i++)
        {
            var segment = Segments[i];
            if (segment.IsPackOnly)
            {
                // Pack-only segments (~) are never included in endpoint routes.
                // Skip them entirely — don't fork, don't add.
                continue;
            }
            if (IsLiteralSegment(segment) || !segment.IsOptional)
            {
                if (expandedPatternSegments.Count == 0)
                {
                    expandedPatternSegments.Add([segment]);
                }
                else
                {
                    for (var j = 0; j < expandedPatternSegments.Count; j++)
                    {
                        var expandedPattern = expandedPatternSegments[j];
                        expandedPattern.Add(segment);
                    }
                }
            }
            else
            {
                var count = expandedPatternSegments.Count;
                if (count == 0)
                {
                    expandedPatternSegments.Add([]);
                    expandedPatternSegments.Add([MakeRequiredSegment(segment)]);
                }
                else
                {
                    for (var j = 0; j < count; j++)
                    {
                        var expandedPattern = expandedPatternSegments[j];
                        expandedPatternSegments.Add([.. expandedPattern, MakeRequiredSegment(segment)]);
                    }
                }
            }
        }

        var result = new List<StaticWebAssetPathPattern>();
        foreach (var expandedPattern in expandedPatternSegments)
        {
            result.Add(new StaticWebAssetPathPattern(expandedPattern));
        }

        return result;

        static StaticWebAssetPathSegment MakeRequiredSegment(StaticWebAssetPathSegment segment) => new()
        {
            Parts = segment.Parts,
            IsOptional = false
        };
    }

    // Computes the label for the pattern. The label is the pattern without the token expressions.
    // The label is used as a stable way to identify any pattern that has token expressions in it.
    // The combination of label + values applied to the pattern uniquely identifies the pattern.
    // For example, the pattern 'file[.{fingerprint}]?.js' has a label of 'file.js'.
    // The combination of 'file.js' + {fingerprint=asdfasdf} uniquely identifies the resolved pattern 'file.asdfasdf.js'.
    // This is leveraged at runtime to identify fingerprinted assets, and create a reverse map from the fingerprinted pattern
    // to the original file without fingerprint.
    internal string ComputePatternLabel()
    {
        var result = new StringBuilder();
        foreach (var segment in Segments)
        {
            if (IsLiteralSegment(segment))
            {
                result.Append(segment.Parts[0].Name);
            }
            continue;
        }

        return result.ToString();
    }

    // Embeds the tokens in the pattern with the values provided by the asset or global resolvers.
    // The embedded values allow tasks to define patterns for related assets/endpoints that retain
    // values from the original asset.
    // For example, when defining endpoints for gzip compressed files. If the origianl asset has a
    // fingerprint token in the pattern, we want the fingerprint of the gzip compressed file to be
    // that of the uncompressed file, not the fingerprint of the compressed file.
    internal void EmbedTokens(StaticWebAsset staticWebAsset, StaticWebAssetTokenResolver resolver)
    {
        foreach (var segment in Segments)
        {
            if (IsLiteralSegment(segment))
            {
                continue;
            }
            var tokenNames = segment.GetTokenNames();
            foreach (var tokenName in tokenNames)
            {
                foreach (var part in segment.Parts)
                {
                    if (part.IsLiteral)
                    {
                        continue;
                    }

                    if (!resolver.TryGetValue(staticWebAsset, tokenName.ToString(), out var tokenValue) || string.IsNullOrEmpty(tokenValue))
                    {
                        continue;
                    }

                    if (part.Name.Span.SequenceEqual(tokenName.Span))
                    {
                        part.Value = tokenValue.AsMemory();
                    }
                }
            }
        }
        RawPattern = GetRawPattern(Segments);
    }

    private static ReadOnlyMemory<char> GetRawPattern(IList<StaticWebAssetPathSegment> segments)
    {
        var stringBuilder = new StringBuilder();
        for (var i = 0; i < segments.Count; i++)
        {
            var segment = segments[i];
            var isLiteral = IsLiteralSegment(segment);
            if (!isLiteral)
            {
                stringBuilder.Append(PatternStart);
            }
            for (var j = 0; j < segment.Parts.Count; j++)
            {
                var part = segment.Parts[j];
                stringBuilder.Append(part.IsLiteral ? part.Name : $$"""{{{(!part.Value.IsEmpty ? $"""{part.Name}{PatternValueSeparator}{part.Value}""" : part.Name)}}}""");
            }
            if (!isLiteral)
            {
                stringBuilder.Append(PatternEnd);
                if (segment.IsPackOnly)
                {
                    stringBuilder.Append(PatternPackOnly);
                }
                else if (segment.IsOptional)
                {
                    if (segment.IsPreferred)
                    {
                        stringBuilder.Append(PatternPreferred);
                    }
                    else
                    {
                        stringBuilder.Append(PatternOptional);
                    }
                }
            }
        }

        return stringBuilder.ToString().AsMemory();
    }

    public override bool Equals(object obj) => Equals(obj as StaticWebAssetPathPattern);

    public bool Equals(StaticWebAssetPathPattern other) =>
        other is not null &&
        MemoryExtensions.Equals(RawPattern.Span, other.RawPattern.Span, StringComparison.Ordinal) &&
        Segments.SequenceEqual(other.Segments);

#if NET47_OR_GREATER
    public override int GetHashCode()
    {
        var hashCode = 1219904980;
        hashCode = (hashCode * -1521134295) + EqualityComparer<ReadOnlyMemory<char>>.Default.GetHashCode(RawPattern);
        hashCode = (hashCode * -1521134295) + EqualityComparer<IList<StaticWebAssetPathSegment>>.Default.GetHashCode(Segments);
        return hashCode;
    }
#else
    public override int GetHashCode()
    {
        var hashCode = new HashCode();
        hashCode.Add(RawPattern);
        for (var i = 0; i < Segments.Count; i++)
        {
            hashCode.Add(Segments[i]);
        }
        return hashCode.ToHashCode();
    }
#endif

    public static bool operator ==(StaticWebAssetPathPattern left, StaticWebAssetPathPattern right) => EqualityComparer<StaticWebAssetPathPattern>.Default.Equals(left, right);

    public static bool operator !=(StaticWebAssetPathPattern left, StaticWebAssetPathPattern right) => !(left == right);

    private string GetDebuggerDisplay() => string.Concat(Segments.Select(s => s.GetDebuggerDisplay()));

    private static bool IsLiteralSegment(StaticWebAssetPathSegment segment) => segment.Parts.Count == 1 && segment.Parts[0].IsLiteral;

    internal static string PathWithoutTokens(string path) => Parse(path).ComputePatternLabel();

    internal static string ExpandIdentityFileNameForFingerprint(string fileNamePattern, string fingerprint)
    {
        var pattern = Parse(fileNamePattern);
        var sb = new StringBuilder();
        foreach (var segment in pattern.Segments)
        {
            var isLiteral = segment.Parts.Count == 1 && segment.Parts[0].IsLiteral;
            if (isLiteral)
            {
                sb.Append(segment.Parts[0].Name);
                continue;
            }

            if (segment.IsOptional && !segment.IsPreferred)
            {
                continue; // skip non-preferred optional segments
            }

            bool missingRequired = false;
            foreach (var part in segment.Parts)
            {
                if (!part.IsLiteral && part.Value.IsEmpty)
                {
                    var tokenName = part.Name.ToString();
                    if (string.Equals(tokenName, "fingerprint", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(fingerprint))
                    {
                        missingRequired = true;
                        break;
                    }
                }
            }
            if (missingRequired)
            {
                if (!segment.IsOptional)
                {
                    throw new InvalidOperationException($"Token 'fingerprint' not provided for '{fileNamePattern}'.");
                }
                continue;
            }

            foreach (var part in segment.Parts)
            {
                if (part.IsLiteral)
                {
                    sb.Append(part.Name);
                }
                else if (!part.Value.IsEmpty)
                {
                    sb.Append(part.Value);
                }
                else
                {
                    var tokenName = part.Name.ToString();
                    if (string.Equals(tokenName, "fingerprint", StringComparison.OrdinalIgnoreCase))
                    {
                        sb.Append(fingerprint);
                    }
                    else
                    {
                        throw new InvalidOperationException($"Unsupported token '{tokenName}' in '{fileNamePattern}'.");
                    }
                }
            }
        }
        return sb.ToString();
    }
}