Skip to content

Commit

Permalink
feat: view source for assemblies using sourcelink (#8548)
Browse files Browse the repository at this point in the history
  • Loading branch information
yufeih authored Mar 25, 2023
1 parent 52e3455 commit 7b5036b
Show file tree
Hide file tree
Showing 18 changed files with 643 additions and 62 deletions.
2 changes: 1 addition & 1 deletion docs/docs/dotnet-api-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Docfx examines the assembly and tries to load the reference assemblies from with
}
```

Features that needs source code information such as "Improve this doc" and "View source" is not available using this approach.
If [source link](https://learn.microsoft.com/en-us/dotnet/standard/library-guidance/sourcelink) is enabled on the assembly and the `.pdb` file exists along side the assembly, docfx shows the "View Source" link based on the source URL extract from source link.

## Generate from projects or solutions

Expand Down
7 changes: 7 additions & 0 deletions samples/seed/dotnet/assembly/BuildFromAssembly.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>
48 changes: 15 additions & 33 deletions src/Microsoft.DocAsCode.Common/Git/GitUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ public static class GitUtility
public static GitDetail TryGetFileDetail(string filePath)
{
if (EnvironmentContext.GitFeaturesDisabled)
{
return null;
}

try
{
Expand All @@ -69,6 +67,20 @@ public static GitDetail TryGetFileDetail(string filePath)
return null;
}

public static string RawContentUrlToContentUrl(string rawUrl)
{
if (EnvironmentContext.GitFeaturesDisabled)
return null;

var branch = Environment.GetEnvironmentVariable("DOCFX_SOURCE_BRANCH_NAME");

// GitHub
return Regex.Replace(
rawUrl,
@"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$",
string.IsNullOrEmpty(branch) ? "https://github.com/$1/$2/blob/$3/$4" : $"https://github.com/$1/$2/blob/{branch}/$4");
}

[Obsolete("Docfx parses repoUrl in template preprocessor. This method is never used.")]
public static GitRepoInfo Parse(string repoUrl)
{
Expand Down Expand Up @@ -281,36 +293,6 @@ private static void ProcessErrorMessage(string message)
throw new GitException(message);
}

private static string TryRunGitCommand(string repoPath, string arguments)
{
var content = new StringBuilder();
try
{
RunGitCommand(repoPath, arguments, output => content.AppendLine(output));
}
catch (Exception ex)
{
Logger.LogWarning($"Skipping RunGitCommand. Exception found: {ex.GetType()}, Message: {ex.Message}");
Logger.LogVerbose(ex.ToString());
}
return content.Length == 0 ? null : content.ToString();
}

private static string TryRunGitCommandAndGetLastLine(string repoPath, string arguments)
{
string content = null;
try
{
content = RunGitCommandAndGetLastLine(repoPath, arguments);
}
catch (Exception ex)
{
Logger.LogWarning($"Skipping RunGitCommandAndGetLastLine. Exception found: {ex.GetType()}, Message: {ex.Message}");
Logger.LogVerbose(ex.ToString());
}
return content;
}

private static string RunGitCommandAndGetLastLine(string repoPath, string arguments)
{
string content = null;
Expand Down Expand Up @@ -390,4 +372,4 @@ private static bool ExistGitCommand()
}

#endregion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ await LoadCompilationFromProject(project.AbsolutePath) is { } compilation)
foreach (var (assembly, compilation) in assemblies)
{
Logger.LogInfo($"Processing {assembly.Name}");
var projectMetadata = assembly.Accept(new SymbolVisitorAdapter(compilation, new YamlModelGenerator(), _config, filter, extensionMethods));
var projectMetadata = assembly.Accept(new SymbolVisitorAdapter(compilation, new(compilation), _config, filter, extensionMethods));

if (projectMetadata != null)
projectMetadataList.Add(projectMetadata);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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.

#nullable disable

using System;

namespace Microsoft.CodeAnalysis.Debugging
{
internal static class PortableCustomDebugInfoKinds
{
public static readonly Guid AsyncMethodSteppingInformationBlob = new("54FD2AC5-E925-401A-9C2A-F94F171072F8");
public static readonly Guid StateMachineHoistedLocalScopes = new("6DA9A61E-F8C7-4874-BE62-68BC5630DF71");
public static readonly Guid DynamicLocalVariables = new("83C563C4-B4F3-47D5-B824-BA5441477EA8");
public static readonly Guid TupleElementNames = new("ED9FDF71-8879-4747-8ED3-FE5EDE3CE710");
public static readonly Guid DefaultNamespace = new("58b2eab6-209f-4e4e-a22c-b2d0f910c782");
public static readonly Guid EncLocalSlotMap = new("755F52A8-91C5-45BE-B4B8-209571E552BD");
public static readonly Guid EncLambdaAndClosureMap = new("A643004C-0240-496F-A783-30D64F4979DE");
public static readonly Guid EncStateMachineStateMap = new("8B78CD68-2EDE-420B-980B-E15884B8AAA3");
public static readonly Guid SourceLink = new("CC110556-A091-4D38-9FEC-25AB9A351A6A");
public static readonly Guid EmbeddedSource = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE");
public static readonly Guid CompilationMetadataReferences = new("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D");
public static readonly Guid CompilationOptions = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8");
public static readonly Guid TypeDefinitionDocuments = new("932E74BC-DBA9-4478-8D46-0F32A7BAB3D3");
}
}
231 changes: 231 additions & 0 deletions src/Microsoft.DocAsCode.Dotnet/SourceLink/SourceLinkMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// 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 NETCOREAPP
using System.Diagnostics.CodeAnalysis;
#endif

#nullable enable

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
{
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<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 NETCOREAPP
[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(new[] { '/', '\\' }).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;
}
}
}
Loading

0 comments on commit 7b5036b

Please sign in to comment.