File: PdbSourceDocument\SourceLinkMap.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
 
#if NET
using System.Diagnostics.CodeAnalysis;
#endif
 
namespace Microsoft.SourceLink.Tools;
 
/// <summary>
/// Source Link URL map. Maps file paths matching Source Link patterns to URLs.
/// </summary>
internal readonly struct SourceLinkMap
{
    private readonly ReadOnlyCollection<Entry> _entries;
 
    private SourceLinkMap(ReadOnlyCollection<Entry> mappings)
    {
        _entries = mappings;
    }
 
    public readonly struct Entry(FilePathPattern filePath, UriPattern uri)
    {
        public readonly FilePathPattern FilePath = filePath;
        public readonly UriPattern Uri = uri;
 
        public void Deconstruct(out FilePathPattern filePath, out UriPattern uri)
        {
            filePath = FilePath;
            uri = Uri;
        }
    }
 
    public readonly struct FilePathPattern(string path, bool isPrefix)
    {
        public readonly string Path = path;
        public readonly bool IsPrefix = isPrefix;
    }
 
    public readonly struct UriPattern(string prefix, string suffix)
    {
        public readonly string Prefix = prefix;
        public readonly string Suffix = suffix;
    }
 
    public IReadOnlyList<Entry> Entries => _entries;
 
    /// <summary>
    /// Parses Source Link JSON string.
    /// </summary>
    /// <exception cref="ArgumentNullException"><paramref name="json"/> is null.</exception>
    /// <exception cref="InvalidDataException">The JSON does not follow Source Link specification.</exception>
    /// <exception cref="JsonException"><paramref name="json"/> is not valid JSON string.</exception>
    public static SourceLinkMap Parse(string json)
    {
        if (json is null)
        {
            throw new ArgumentNullException(nameof(json));
        }
 
        var list = new List<Entry>();
 
        var root = JsonDocument.Parse(json, new JsonDocumentOptions() { AllowTrailingCommas = true }).RootElement;
        if (root.ValueKind != JsonValueKind.Object)
        {
            throw new InvalidDataException();
        }
 
        foreach (var rootEntry in root.EnumerateObject())
        {
            if (!rootEntry.NameEquals("documents"))
            {
                // potential future extensibility
                continue;
            }
 
            if (rootEntry.Value.ValueKind != JsonValueKind.Object)
            {
                throw new InvalidDataException();
            }
 
            foreach (var documentsEntry in rootEntry.Value.EnumerateObject())
            {
                if (documentsEntry.Value.ValueKind != JsonValueKind.String ||
                    !TryParseEntry(documentsEntry.Name, documentsEntry.Value.GetString()!, out var entry))
                {
                    throw new InvalidDataException();
                }
 
                list.Add(entry);
            }
        }
 
        // Sort the map by decreasing file path length. This ensures that the most specific paths will checked before the least specific
        // and that absolute paths will be checked before a wildcard path with a matching base
        list.Sort((left, right) => -left.FilePath.Path.Length.CompareTo(right.FilePath.Path.Length));
 
        return new SourceLinkMap(new ReadOnlyCollection<Entry>(list));
    }
 
    private static bool TryParseEntry(string key, string value, out Entry entry)
    {
        entry = default;
 
        // VALIDATION RULES
        // 1. The only acceptable wildcard is one and only one '*', which if present will be replaced by a relative path
        // 2. If the filepath does not contain a *, the uri cannot contain a * and if the filepath contains a * the uri must contain a *
        // 3. If the filepath contains a *, it must be the final character
        // 4. If the uri contains a *, it may be anywhere in the uri
        if (key.Length == 0)
        {
            return false;
        }
 
        var filePathStar = key.IndexOf('*');
        if (filePathStar == key.Length - 1)
        {
            key = key[..filePathStar];
        }
        else if (filePathStar >= 0)
        {
            return false;
        }
 
        string uriPrefix, uriSuffix;
        var uriStar = value.IndexOf('*');
        if (uriStar >= 0)
        {
            if (filePathStar < 0)
            {
                return false;
            }
 
            uriPrefix = value[..uriStar];
            uriSuffix = value[(uriStar + 1)..];
 
            if (uriSuffix.IndexOf('*') >= 0)
            {
                return false;
            }
        }
        else
        {
            uriPrefix = value;
            uriSuffix = "";
        }
 
        entry = new Entry(
            new FilePathPattern(key, isPrefix: filePathStar >= 0),
            new UriPattern(uriPrefix, uriSuffix));
 
        return true;
    }
 
    /// <summary>
    /// Maps specified <paramref name="path"/> to the corresponding URL.
    /// </summary>
    /// <exception cref="ArgumentNullException"><paramref name="path"/> is null.</exception>
    public bool TryGetUri(
        string path,
#if NET
        [NotNullWhen(true)]
#endif
        out string? uri)
    {
        if (path == null)
        {
            throw new ArgumentNullException(nameof(path));
        }
 
        if (path.IndexOf('*') >= 0)
        {
            uri = null;
            return false;
        }
 
        // Note: the mapping function is case-insensitive.
 
        foreach (var (file, mappedUri) in _entries)
        {
            if (file.IsPrefix)
            {
                if (path.StartsWith(file.Path, StringComparison.OrdinalIgnoreCase))
                {
                    var escapedPath = string.Join("/", path[file.Path.Length..].Split(['/', '\\']).Select(Uri.EscapeDataString));
                    uri = mappedUri.Prefix + escapedPath + mappedUri.Suffix;
                    return true;
                }
            }
            else if (string.Equals(path, file.Path, StringComparison.OrdinalIgnoreCase))
            {
                Debug.Assert(mappedUri.Suffix.Length == 0);
                uri = mappedUri.Prefix;
                return true;
            }
        }
 
        uri = null;
        return false;
    }
}