File: BuiltInMetadataExpander.cs
Web Access
Project: src\msbuild\src\Framework\Microsoft.Build.Framework.csproj (Microsoft.Build.Framework)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.NET.StringTools;

namespace Microsoft.Build.Framework;

/// <summary>
///  Expands built-in metadata references, such as <c>%(Filename)</c>, against an item.
/// </summary>
/// <remarks>
///  A deliberately minimal stand-in for the evaluation expander, which also handles properties, item vectors,
///  custom metadata, transforms and truncation. Built-in metadata references are the only expression form that
///  survives evaluation unexpanded, so they are the only one that can reach a task host still unexpanded. This
///  lives in Framework because the evaluation expander is internal to <c>Microsoft.Build</c>, while this is needed
///  by <c>MSBuild</c> and <c>Microsoft.Build.Tasks</c> too.
///
///  Keep in step with <c>Expander.ExpandIntoStringLeaveEscaped</c> under <c>ExpanderOptions.ExpandBuiltInMetadata</c>,
///  which is what <c>ProjectItemInstance.TaskItem.GetMetadataEscaped</c> uses for the same job in-proc.
/// </remarks>
internal static class BuiltInMetadataExpander
{
    /// <summary>
    ///  Expands every built-in metadata reference in <paramref name="escapedValue"/> against the given item.
    ///  Anything else, including a reference that cannot be satisfied, is left as it is.
    /// </summary>
    /// <param name="escapedValue">The escaped value to expand.</param>
    /// <param name="escapedItemSpec">The escaped item spec that built-in metadata is derived from.</param>
    /// <param name="escapedDefiningProject">The escaped path of the project that defined the item.</param>
    /// <param name="escapedRecursiveDir">
    ///  The item's RecursiveDir, which comes from the wildcard the item was expanded from rather than the item spec.
    /// </param>
    /// <param name="cache">Cache of already derived modifier values for this item.</param>
    /// <returns>The value with built-in metadata references expanded.</returns>
    internal static string? Expand(
        string? escapedValue,
        string escapedItemSpec,
        string? escapedDefiningProject,
        string? escapedRecursiveDir,
        ref ItemSpecModifiers.Cache cache)
    {
        int index = escapedValue is null ? -1 : IndexOfMetadataMarker(escapedValue, 0);

        if (index < 0)
        {
            return escapedValue;
        }

        SpanBasedStringBuilder? builder = null;
        int copiedUpTo = 0;

        try
        {
            while (index >= 0)
            {
                // No closing parenthesis anywhere after this point means no reference can close, so stop.
                int closingParenthesis = escapedValue!.IndexOf(')', index + 2);

                if (closingParenthesis < 0)
                {
                    break;
                }

                if (TryGetModifier(escapedValue, index + 2, closingParenthesis, out ItemSpecModifierKind kind))
                {
                    builder ??= Strings.GetSpanBasedStringBuilder();
                    builder.Append(escapedValue, copiedUpTo, index - copiedUpTo);
                    builder.Append(kind is ItemSpecModifierKind.RecursiveDir
                        ? escapedRecursiveDir ?? string.Empty
                        : ItemSpecModifiers.GetItemSpecModifier(escapedItemSpec, kind, currentDirectory: null, escapedDefiningProject, ref cache));
                    copiedUpTo = closingParenthesis + 1;

                    index = IndexOfMetadataMarker(escapedValue, copiedUpTo);
                }
                else
                {
                    // This "%(" does not start a reference. Resume just after it rather than after the
                    // parenthesis, because a well formed reference can begin inside the text it spanned,
                    // as in "%(foo%(Filename)". The evaluation expander advances the same way.
                    index = IndexOfMetadataMarker(escapedValue, index + 2);
                }
            }

            if (builder is null)
            {
                return escapedValue;
            }

            builder.Append(escapedValue!, copiedUpTo, escapedValue!.Length - copiedUpTo);
            return builder.ToString();
        }
        finally
        {
            builder?.Dispose();
        }
    }

    /// <summary>
    ///  Finds the first <c>%(</c> at or after <paramref name="startIndex"/>, or -1 if there is none.
    ///  Does not check that a well formed reference follows it.
    /// </summary>
    /// <remarks>
    ///  <c>IndexOf(char)</c> vectorizes, and is significantly faster than an ordinal two-character search when
    ///  the marker is absent, which is the usual case for a metadata value. So look for <c>%</c> alone and test
    ///  the next character separately.
    /// </remarks>
    internal static int IndexOfMetadataMarker(string value, int startIndex)
    {
        int markerIndex = value.IndexOf('%', startIndex);

        // A marker in the last position has no room for the parenthesis.
        while (markerIndex >= 0 && markerIndex < value.Length - 1)
        {
            int nextIndex = markerIndex + 1;

            if (value[nextIndex] == '(')
            {
                return markerIndex;
            }

            markerIndex = value.IndexOf('%', nextIndex);
        }

        return -1;
    }

    /// <summary>
    ///  Reads the metadata name between <c>%(</c> and its closing parenthesis and resolves it to a built-in
    ///  metadata kind, allowing surrounding whitespace as the evaluation expander does. A name qualified by an item
    ///  type is rejected, since the engine resolves built-in metadata against a table with no item type and so
    ///  never satisfies one either.
    /// </summary>
    private static bool TryGetModifier(string value, int start, int end, out ItemSpecModifierKind kind)
    {
        while (start < end && char.IsWhiteSpace(value[start]))
        {
            start++;
        }

        while (end > start && char.IsWhiteSpace(value[end - 1]))
        {
            end--;
        }

        if (end <= start)
        {
            kind = default;
            return false;
        }

        return ItemSpecModifiers.TryGetModifierKind(value.Substring(start, end - start), out kind);
    }
}