diff --git a/SourceLink.sln b/SourceLink.sln
index b185660ea..39b64cbdc 100644
--- a/SourceLink.sln
+++ b/SourceLink.sln
@@ -58,7 +58,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.GitWeb
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.GitWeb.UnitTests", "src\SourceLink.GitWeb.UnitTests\Microsoft.SourceLink.GitWeb.UnitTests.csproj", "{50503A43-08C0-493B-B8CC-F368983644C1}"
EndProject
+Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Microsoft.SourceLink.Tools", "src\SourceLink.Tools\Microsoft.SourceLink.Tools.shproj", "{5DF76CC2-5F0E-45A6-AD56-6BBBCCBC1A78}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.Tools.Package", "src\SourceLink.Tools\Microsoft.SourceLink.Tools.Package.csproj", "{141E6850-B424-47AD-884A-CED362027382}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.Tools.UnitTests", "src\SourceLink.Tools.UnitTests\Microsoft.SourceLink.Tools.UnitTests.csproj", "{99D113A9-24EC-471D-9F74-D2AC2F16220B}"
+EndProject
Global
+ GlobalSection(SharedMSBuildProjectFiles) = preSolution
+ src\SourceLink.Tools\Microsoft.SourceLink.Tools.projitems*{5df76cc2-5f0e-45a6-ad56-6bbbccbc1a78}*SharedItemsImports = 13
+ src\SourceLink.Tools\Microsoft.SourceLink.Tools.projitems*{99d113a9-24ec-471d-9f74-d2ac2f16220b}*SharedItemsImports = 5
+ EndGlobalSection
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
@@ -148,6 +158,14 @@ Global
{50503A43-08C0-493B-B8CC-F368983644C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{50503A43-08C0-493B-B8CC-F368983644C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{50503A43-08C0-493B-B8CC-F368983644C1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {141E6850-B424-47AD-884A-CED362027382}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {141E6850-B424-47AD-884A-CED362027382}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {141E6850-B424-47AD-884A-CED362027382}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {141E6850-B424-47AD-884A-CED362027382}.Release|Any CPU.Build.0 = Release|Any CPU
+ {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/eng/Versions.props b/eng/Versions.props
index 1955a904c..ec61a7739 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -21,6 +21,7 @@
2.1.0
4.9.2
4.5.0
+ 4.7.2
0.26.0-preview-0070
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index d60172286..a4b4d2c13 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -17,7 +17,11 @@
false
-
+
+
$(_ProjectDefinedPackageId)
$(AssemblyName)
diff --git a/src/SourceLink.Tools.UnitTests/Microsoft.SourceLink.Tools.UnitTests.csproj b/src/SourceLink.Tools.UnitTests/Microsoft.SourceLink.Tools.UnitTests.csproj
new file mode 100644
index 000000000..194ea0a73
--- /dev/null
+++ b/src/SourceLink.Tools.UnitTests/Microsoft.SourceLink.Tools.UnitTests.csproj
@@ -0,0 +1,12 @@
+
+
+ net472;netcoreapp3.1
+
+
+
+
+
+
+
+
+
diff --git a/src/SourceLink.Tools.UnitTests/SourceLinkMapTests.cs b/src/SourceLink.Tools.UnitTests/SourceLinkMapTests.cs
new file mode 100644
index 000000000..e143fae15
--- /dev/null
+++ b/src/SourceLink.Tools.UnitTests/SourceLinkMapTests.cs
@@ -0,0 +1,194 @@
+// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using TestUtilities;
+using Xunit;
+
+namespace Microsoft.SourceLink.Tools.UnitTests
+{
+ public class SourceLinkMapTests
+ {
+ private IEnumerable Inspect(SourceLinkMap map)
+ => map.Entries.Select(e => $"('{e.FilePath.Path}', {(e.FilePath.IsPrefix ? "*" : "")}) -> ('{e.Uri.Prefix}', '{e.Uri.Suffix}')");
+
+ [Theory]
+ [InlineData(@"{}")]
+ [InlineData(@"{""xxx"":{}}")]
+ [InlineData(@"{""documents"":{}}")]
+ public void Empty(string json)
+ {
+ var map = SourceLinkMap.Parse(json);
+ Assert.Empty(map.Entries);
+ }
+
+ [Fact]
+ public void Extra()
+ {
+ var map = SourceLinkMap.Parse(@"
+{
+ ""documents"" :
+ {
+ ""C:\\a*"": ""http://server/1/a*"",
+ },
+ ""extra"": {}
+}");
+ AssertEx.Equal(new[] { "('C:\\a', *) -> ('http://server/1/a', '')" }, Inspect(map));
+ }
+
+ [Fact]
+ public void Entries()
+ {
+ var map = SourceLinkMap.Parse(@"
+{
+ ""documents"" :
+ {
+ ""C:\\a*"": ""http://server/[*]"",
+ ""C:\\a*"": ""http://a/"",
+ ""C:\\a"": ""http://a/"",
+ ""C:\\a*"": ""http://*a"",
+ ""C:\\b"": ""http://b"",
+ }
+}");
+ AssertEx.Equal(new[]
+ {
+ @"('C:\a', *) -> ('http://server/[', ']')",
+ @"('C:\a', *) -> ('http://a/', '')",
+ @"('C:\a', ) -> ('http://a/', '')",
+ @"('C:\a', *) -> ('http://', 'a')",
+ @"('C:\b', ) -> ('http://b', '')"
+ }, Inspect(map));
+
+ Assert.True(map.TryGetUrl(@"C:\a", out var url));
+ Assert.Equal("http://server/[]", url);
+
+ Assert.True(map.TryGetUrl(@"C:\a\b\c\d\e", out url));
+ Assert.Equal("http://server/[/b/c/d/e]", url);
+
+ Assert.True(map.TryGetUrl(@"C:\b", out url));
+ Assert.Equal("http://b", url);
+
+ Assert.False(map.TryGetUrl(@"C:\b\c", out _));
+ }
+
+ [Fact]
+ public void Order1()
+ {
+ var map = SourceLinkMap.Parse(@"
+{
+ ""documents"" :
+ {
+ ""C:\\a\\b*"": ""2:*"",
+ ""C:\\a\\b\\c*"": ""1:*"",
+ ""C:\\a*"": ""3:*"",
+ }
+}");
+ AssertEx.Equal(new[]
+ {
+ @"('C:\a\b\c', *) -> ('1:', '')",
+ @"('C:\a\b', *) -> ('2:', '')",
+ @"('C:\a', *) -> ('3:', '')"
+ }, Inspect(map));
+
+ string? url;
+ Assert.True(map.TryGetUrl(@"C:\a\b\c\d\e", out url));
+ Assert.Equal("1:/d/e", url);
+
+ Assert.True(map.TryGetUrl(@"C:\a\b\", out url));
+ Assert.Equal("2:/", url);
+
+ Assert.True(map.TryGetUrl(@"C:\a\x", out url));
+ Assert.Equal("3:/x", url);
+
+ Assert.False(map.TryGetUrl(@"D:\x", out _));
+ }
+
+ [Fact]
+ public void Order2()
+ {
+ var map = SourceLinkMap.Parse(@"
+{
+ ""documents"" :
+ {
+ ""C:\\aaa\\bbb*"": ""1:*"",
+ ""C:\\aaa\\bb*"": ""2:*"",
+ }
+}");
+ AssertEx.Equal(new[]
+ {
+ @"('C:\aaa\bbb', *) -> ('1:', '')",
+ @"('C:\aaa\bb', *) -> ('2:', '')",
+ }, Inspect(map));
+
+ string? url;
+ Assert.True(map.TryGetUrl(@"C:\aaa\bbbb", out url));
+ Assert.Equal("1:b", url);
+
+ Assert.True(map.TryGetUrl(@"C:\aaa\bbb", out url));
+ Assert.Equal("1:", url);
+
+ Assert.True(map.TryGetUrl(@"C:\aaa\bb", out url));
+ Assert.Equal("2:", url);
+
+ Assert.False(map.TryGetUrl(@"C:\aaa\b", out _));
+ }
+
+ [Fact]
+ public void TryGetUrl_Star()
+ {
+ var map = SourceLinkMap.Parse(@"{""documents"":{}}");
+ Assert.False(map.TryGetUrl("path*", out _));
+ }
+
+ [Fact]
+ public void TryGetUrl_InvalidArgument()
+ {
+ var map = SourceLinkMap.Parse(@"{""documents"":{}}");
+ Assert.Throws(() => map.TryGetUrl(null!, out _));
+ }
+
+ [Theory]
+ [InlineData(@"{")]
+ [InlineData(@"{""documents"" : { ""x"": ""y"" // comments not allowed
+} }")]
+ [InlineData(@"{""documents"" : { 1: ""y"" } }")]
+ public void BadJson_Key(string json)
+ {
+ Assert.ThrowsAny(() => SourceLinkMap.Parse(json));
+ }
+
+ [Theory]
+ [InlineData(@"1")]
+ [InlineData(@"{""documents"": 1}")]
+ [InlineData(@"{""documents"":{""1"": 1}}")]
+ [InlineData(@"{""documents"":{""1"": null}}")]
+ [InlineData(@"{""documents"":{""1"": {}}}")]
+ [InlineData(@"{""documents"":{""1"": []}}")]
+ public void InvalidJsonTypes(string json)
+ {
+ Assert.Throws(() => SourceLinkMap.Parse(json));
+ }
+
+ [Theory]
+ [InlineData(@"{""documents"":{"""": ""x""}}")]
+ [InlineData(@"{""documents"":{""1**"": ""x""}}")]
+ [InlineData(@"{""documents"":{""*1*"": ""x""}}")]
+ [InlineData(@"{""documents"":{""1*"": ""**x""}}")]
+ [InlineData(@"{""documents"":{""*1"": ""x*""}}")]
+ [InlineData(@"{""documents"":{""1"": ""x*""}}")]
+ public void InvalidWildcards(string json)
+ {
+ Assert.Throws(() => SourceLinkMap.Parse(json));
+ }
+
+ [Fact]
+ public void Parse_InvalidArgument()
+ {
+ Assert.Throws(() => SourceLinkMap.Parse(null!));
+ }
+ }
+}
diff --git a/src/SourceLink.Tools/Microsoft.SourceLink.Tools.Package.csproj b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.Package.csproj
new file mode 100644
index 000000000..578cfef0c
--- /dev/null
+++ b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.Package.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+ netcoreapp3.1;net472
+ false
+ none
+ false
+
+
+ true
+ true
+ Microsoft.SourceLink.Tools
+ false
+
+ Package containing sources of tools for reading Source Link.
+
+
+ $(NoWarn);NU5128
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SourceLink.Tools/Microsoft.SourceLink.Tools.projitems b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.projitems
new file mode 100644
index 000000000..e52c86e4f
--- /dev/null
+++ b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.projitems
@@ -0,0 +1,14 @@
+
+
+
+ $(MSBuildAllProjects);$(MSBuildThisFileFullPath)
+ true
+ 5df76cc2-5f0e-45a6-ad56-6bbbccbc1a78
+
+
+ Microsoft.SourceLink.Tools
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SourceLink.Tools/Microsoft.SourceLink.Tools.shproj b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.shproj
new file mode 100644
index 000000000..9e5b2a149
--- /dev/null
+++ b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.shproj
@@ -0,0 +1,13 @@
+
+
+
+ 5df76cc2-5f0e-45a6-ad56-6bbbccbc1a78
+ 14.0
+
+
+
+
+
+
+
+
diff --git a/src/SourceLink.Tools/SourceLinkMap.cs b/src/SourceLink.Tools/SourceLinkMap.cs
new file mode 100644
index 000000000..776c75c63
--- /dev/null
+++ b/src/SourceLink.Tools/SourceLinkMap.cs
@@ -0,0 +1,229 @@
+// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+
+#if NETCOREAPP
+using System.Diagnostics.CodeAnalysis;
+#endif
+
+namespace Microsoft.SourceLink.Tools
+{
+ ///
+ /// Source Link URL map. Maps file paths matching Source Link patterns to URLs.
+ ///
+ internal readonly struct SourceLinkMap
+ {
+ private readonly ReadOnlyCollection _entries;
+
+ private SourceLinkMap(ReadOnlyCollection mappings)
+ {
+ _entries = mappings;
+ }
+
+ public readonly struct Entry
+ {
+ public readonly FilePathPattern FilePath;
+ public readonly UriPattern Uri;
+
+ public Entry(FilePathPattern filePath, UriPattern uri)
+ {
+ FilePath = filePath;
+ Uri = uri;
+ }
+
+ public void Deconstruct(out FilePathPattern filePath, out UriPattern uri)
+ {
+ filePath = FilePath;
+ uri = Uri;
+ }
+ }
+
+ public readonly struct FilePathPattern
+ {
+ public readonly string Path;
+ public readonly bool IsPrefix;
+
+ public FilePathPattern(string path, bool isPrefix)
+ {
+ Path = path;
+ IsPrefix = isPrefix;
+ }
+ }
+
+ public readonly struct UriPattern
+ {
+ public readonly string Prefix;
+ public readonly string Suffix;
+
+ public UriPattern(string prefix, string suffix)
+ {
+ Prefix = prefix;
+ Suffix = suffix;
+ }
+ }
+
+ public IReadOnlyList Entries => _entries;
+
+ ///
+ /// Parses Source Link JSON string.
+ ///
+ /// is null.
+ /// The JSON does not follow Source Link specification.
+ /// is not valid JSON string.
+ public static SourceLinkMap Parse(string json)
+ {
+ if (json is null)
+ {
+ throw new ArgumentNullException(nameof(json));
+ }
+
+ var list = new List();
+
+ 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(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;
+ }
+
+ int filePathStar = key.IndexOf('*');
+ if (filePathStar == key.Length - 1)
+ {
+ key = key.Substring(0, filePathStar);
+ }
+ else if (filePathStar >= 0)
+ {
+ return false;
+ }
+
+ string uriPrefix, uriSuffix;
+ int uriStar = value.IndexOf('*');
+ if (uriStar >= 0)
+ {
+ if (filePathStar < 0)
+ {
+ return false;
+ }
+
+ uriPrefix = value.Substring(0, uriStar);
+ uriSuffix = value.Substring(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;
+ }
+
+ ///
+ /// Maps specified to the corresponding URL.
+ ///
+ /// is null.
+ public bool TryGetUrl(
+ string path,
+#if NETCOREAPP
+ [NotNullWhen(true)]
+#endif
+ out string? url)
+ {
+ if (path == null)
+ {
+ throw new ArgumentNullException(nameof(path));
+ }
+
+ if (path.IndexOf('*') >= 0)
+ {
+ url = null;
+ return false;
+ }
+
+ // Note: the mapping function is case-insensitive.
+
+ foreach (var (file, mappedUrl) in _entries)
+ {
+ if (file.IsPrefix)
+ {
+ if (path.StartsWith(file.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ var escapedPath = string.Join("/", path.Substring(file.Path.Length).Split(new[] { '/', '\\' }).Select(Uri.EscapeDataString));
+ url = mappedUrl.Prefix + escapedPath + mappedUrl.Suffix;
+ return true;
+ }
+ }
+ else if (string.Equals(path, file.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ Debug.Assert(mappedUrl.Suffix.Length == 0);
+ url = mappedUrl.Prefix;
+ return true;
+ }
+ }
+
+ url = null;
+ return false;
+ }
+ }
+}
\ No newline at end of file