File: PathMap.cs
Web Access
Project: ILAssembler.csproj (ILAssembler)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;

namespace ILAssembler;

/// <summary>
/// Maps path prefixes to the paths that the debug information records, as the C# compiler's <c>-pathmap</c> option
/// does, so that the image and its Portable PDB need not depend on where the sources and the output are.
/// </summary>
/// <remarks>
/// <para>
/// A map is an ordered list of entries. Each entry has a key, a path prefix to replace, and a value, the path to
/// write in its place. Both end with a directory separator (see <see cref="TryParse"/>), so a key matches whole
/// leading directories only: the key <c>/src/</c> matches <c>/src/a.il</c> but not <c>/srcx/a.il</c>.
/// </para>
/// <para>
/// <see cref="Map"/> replaces the key of the first entry that is a prefix of the path. Keys are compared ordinally,
/// so case matters on every platform.
/// </para>
/// </remarks>
public sealed class PathMap
{
    private PathMap(ImmutableArray<KeyValuePair<string, string>> entries)
    {
        Entries = entries;
    }

    /// <summary>Gets the map without entries, which maps every path to itself.</summary>
    public static PathMap Empty { get; } = new(ImmutableArray<KeyValuePair<string, string>>.Empty);

    /// <summary>
    /// Gets the entries in the order they were given: each key, the path prefix to replace, and value, the path
    /// written in its place, both ending with a directory separator.
    /// </summary>
    public ImmutableArray<KeyValuePair<string, string>> Entries { get; }

    /// <summary>
    /// Parses a path map written as the C# compiler's <c>-pathmap</c> option takes it:
    /// <c>path1=sourcePath1,path2=sourcePath2,...</c>.
    /// </summary>
    /// <param name="text">The text to parse. An empty text is the empty map.</param>
    /// <param name="pathMap">The map, with its entries in the order of the text, when the text is valid.</param>
    /// <param name="error">A message that names the invalid entry, when the text is not valid.</param>
    /// <returns><see langword="true"/> when the text is valid.</returns>
    /// <remarks>
    /// <para>
    /// Entries are separated by <c>,</c>, and an entry's key is separated from its value by <c>=</c>. A doubled
    /// <c>,,</c> or <c>==</c> stands for the character itself, so a path can contain either. An empty entry, as
    /// after a trailing <c>,</c>, is skipped. An entry that does not have exactly one separating <c>=</c>, or whose
    /// key or value is empty, is an error.
    /// </para>
    /// <para>
    /// A key or value that does not end with <c>/</c> or <c>\</c> gets a directory separator appended: the one it
    /// already uses when it uses only <c>/</c> or only <c>\</c>, and otherwise
    /// <see cref="Path.DirectorySeparatorChar"/>.
    /// </para>
    /// </remarks>
    public static bool TryParse(string text, [NotNullWhen(true)] out PathMap? pathMap, [NotNullWhen(false)] out string? error)
    {
        var entries = ImmutableArray.CreateBuilder<KeyValuePair<string, string>>();
        foreach (string entry in SplitWithDoubledSeparatorEscaping(text, ','))
        {
            if (entry.Length == 0)
            {
                continue;
            }

            List<string> keyAndValue = SplitWithDoubledSeparatorEscaping(entry, '=');
            if (keyAndValue.Count != 2)
            {
                pathMap = null;
                error = $"Invalid path map entry '{entry}': expected <path>=<sourcePath>";
                return false;
            }

            if (keyAndValue[0].Length == 0 || keyAndValue[1].Length == 0)
            {
                pathMap = null;
                error = $"Invalid path map entry '{entry}': the path and the source path must not be empty";
                return false;
            }

            entries.Add(new KeyValuePair<string, string>(
                EnsureTrailingSeparator(keyAndValue[0]),
                EnsureTrailingSeparator(keyAndValue[1])));
        }

        pathMap = entries.Count == 0 ? Empty : new PathMap(entries.ToImmutable());
        error = null;
        return true;
    }

    /// <summary>
    /// Gets a map with the entries of this map followed by those of <paramref name="other"/>. Since the first
    /// matching entry wins, an entry of this map takes precedence over one of <paramref name="other"/>.
    /// </summary>
    public PathMap Concat(PathMap other)
    {
        if (other.Entries.IsEmpty)
        {
            return this;
        }

        return Entries.IsEmpty ? other : new PathMap(Entries.AddRange(other.Entries));
    }

    /// <summary>Gets the path to record for <paramref name="path"/>.</summary>
    /// <remarks>
    /// The first entry whose key is a prefix of the path, compared ordinally, gives the result: its value followed by
    /// the rest of the path. If that value uses only <c>/</c>, every <c>\</c> in the result becomes <c>/</c>; if it
    /// uses only <c>\</c>, every <c>/</c> becomes <c>\</c>. A path that no key matches, such as a relative path when
    /// the keys are full paths, is returned unchanged.
    /// </remarks>
    public string Map(string path)
    {
        foreach (KeyValuePair<string, string> entry in Entries)
        {
            if (!path.StartsWith(entry.Key, StringComparison.Ordinal))
            {
                continue;
            }

            string mapped = string.Concat(entry.Value, path.AsSpan(entry.Key.Length));
            bool hasSlash = entry.Value.Contains('/');
            bool hasBackslash = entry.Value.Contains('\\');
            if (hasSlash && !hasBackslash)
            {
                return mapped.Replace('\\', '/');
            }

            if (hasBackslash && !hasSlash)
            {
                return mapped.Replace('/', '\\');
            }

            return mapped;
        }

        return path;
    }

    // Splits the text at each single separator; a doubled separator stands for the character itself.
    private static List<string> SplitWithDoubledSeparatorEscaping(string text, char separator)
    {
        var parts = new List<string>();
        if (text.Length == 0)
        {
            return parts;
        }

        var part = new StringBuilder();
        for (int i = 0; i < text.Length; i++)
        {
            char c = text[i];
            if (c == separator)
            {
                if (i + 1 < text.Length && text[i + 1] == separator)
                {
                    i++;
                }
                else
                {
                    parts.Add(part.ToString());
                    part.Clear();
                    continue;
                }
            }

            part.Append(c);
        }

        parts.Add(part.ToString());
        return parts;
    }

    private static string EnsureTrailingSeparator(string path)
    {
        char last = path[^1];
        if (last is '/' or '\\')
        {
            return path;
        }

        bool hasSlash = path.Contains('/');
        bool hasBackslash = path.Contains('\\');
        char separator = hasSlash && !hasBackslash ? '/'
            : hasBackslash && !hasSlash ? '\\'
            : Path.DirectorySeparatorChar;
        return path + separator;
    }
}