From 7b5036b315e1833a45760f3c9845ef7e7b386f7c Mon Sep 17 00:00:00 2001 From: Yufei Huang Date: Sat, 25 Mar 2023 14:46:02 +0800 Subject: [PATCH] feat: view source for assemblies using sourcelink (#8548) --- docs/docs/dotnet-api-docs.md | 2 +- .../dotnet/assembly/BuildFromAssembly.csproj | 7 + .../Git/GitUtility.cs | 48 ++-- .../ExtractMetadata/ExtractMetadataWorker.cs | 3 +- .../PortableCustomDebugInfoKinds.cs | 27 ++ .../SourceLink/SourceLinkMap.cs | 231 ++++++++++++++++++ .../SourceLink/SymbolSourceDocumentFinder.cs | 175 +++++++++++++ .../SymbolFormatter.cs | 4 +- .../SymbolHelper.cs | 2 +- .../SymbolUrlResolver.SourceLink.cs | 121 +++++++++ .../SymbolUrlResolver.cs | 5 +- .../Visitors/SymbolVisitorAdapter.cs | 2 +- .../Visitors/VisitorHelper.cs | 11 +- .../Visitors/YamlModelGenerator.cs | 19 +- templates/common/common.js | 4 +- .../SymbolUrlResolverUnitTest.cs | 25 ++ ...romAssembly.Class1.html.view.verified.json | 18 +- .../BuildFromAssembly.html.view.verified.json | 1 + 18 files changed, 643 insertions(+), 62 deletions(-) create mode 100644 src/Microsoft.DocAsCode.Dotnet/SourceLink/PortableCustomDebugInfoKinds.cs create mode 100644 src/Microsoft.DocAsCode.Dotnet/SourceLink/SourceLinkMap.cs create mode 100644 src/Microsoft.DocAsCode.Dotnet/SourceLink/SymbolSourceDocumentFinder.cs create mode 100644 src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.SourceLink.cs diff --git a/docs/docs/dotnet-api-docs.md b/docs/docs/dotnet-api-docs.md index b36adc81ce5..6d4e98e106a 100644 --- a/docs/docs/dotnet-api-docs.md +++ b/docs/docs/dotnet-api-docs.md @@ -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 diff --git a/samples/seed/dotnet/assembly/BuildFromAssembly.csproj b/samples/seed/dotnet/assembly/BuildFromAssembly.csproj index 24cd9d8477a..15c6b13f579 100644 --- a/samples/seed/dotnet/assembly/BuildFromAssembly.csproj +++ b/samples/seed/dotnet/assembly/BuildFromAssembly.csproj @@ -7,4 +7,11 @@ true + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/src/Microsoft.DocAsCode.Common/Git/GitUtility.cs b/src/Microsoft.DocAsCode.Common/Git/GitUtility.cs index f3d9801ea86..a077314c43f 100644 --- a/src/Microsoft.DocAsCode.Common/Git/GitUtility.cs +++ b/src/Microsoft.DocAsCode.Common/Git/GitUtility.cs @@ -52,9 +52,7 @@ public static class GitUtility public static GitDetail TryGetFileDetail(string filePath) { if (EnvironmentContext.GitFeaturesDisabled) - { return null; - } try { @@ -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) { @@ -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; @@ -390,4 +372,4 @@ private static bool ExistGitCommand() } #endregion -} \ No newline at end of file +} diff --git a/src/Microsoft.DocAsCode.Dotnet/ExtractMetadata/ExtractMetadataWorker.cs b/src/Microsoft.DocAsCode.Dotnet/ExtractMetadata/ExtractMetadataWorker.cs index d85657f796c..fd85e4c267c 100644 --- a/src/Microsoft.DocAsCode.Dotnet/ExtractMetadata/ExtractMetadataWorker.cs +++ b/src/Microsoft.DocAsCode.Dotnet/ExtractMetadata/ExtractMetadataWorker.cs @@ -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); } diff --git a/src/Microsoft.DocAsCode.Dotnet/SourceLink/PortableCustomDebugInfoKinds.cs b/src/Microsoft.DocAsCode.Dotnet/SourceLink/PortableCustomDebugInfoKinds.cs new file mode 100644 index 00000000000..4e0e325055a --- /dev/null +++ b/src/Microsoft.DocAsCode.Dotnet/SourceLink/PortableCustomDebugInfoKinds.cs @@ -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"); + } +} diff --git a/src/Microsoft.DocAsCode.Dotnet/SourceLink/SourceLinkMap.cs b/src/Microsoft.DocAsCode.Dotnet/SourceLink/SourceLinkMap.cs new file mode 100644 index 00000000000..f36fa6cabb3 --- /dev/null +++ b/src/Microsoft.DocAsCode.Dotnet/SourceLink/SourceLinkMap.cs @@ -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 +{ + /// + /// 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; + } + + 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; + } + + /// + /// Maps specified to the corresponding URL. + /// + /// is null. + 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; + } + } +} diff --git a/src/Microsoft.DocAsCode.Dotnet/SourceLink/SymbolSourceDocumentFinder.cs b/src/Microsoft.DocAsCode.Dotnet/SourceLink/SymbolSourceDocumentFinder.cs new file mode 100644 index 00000000000..c4308286b10 --- /dev/null +++ b/src/Microsoft.DocAsCode.Dotnet/SourceLink/SymbolSourceDocumentFinder.cs @@ -0,0 +1,175 @@ +// 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.Collections.Generic; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using Microsoft.CodeAnalysis.Debugging; + +namespace Microsoft.CodeAnalysis.PdbSourceDocument +{ + internal static class SymbolSourceDocumentFinder + { + public static HashSet FindDocumentHandles(EntityHandle handle, MetadataReader dllReader, MetadataReader pdbReader) + { + var docList = new HashSet(); + + switch (handle.Kind) + { + case HandleKind.MethodDefinition: + ProcessMethodDef((MethodDefinitionHandle)handle, dllReader, pdbReader, docList, processDeclaringType: true); + break; + case HandleKind.TypeDefinition: + ProcessTypeDef((TypeDefinitionHandle)handle, dllReader, pdbReader, docList); + break; + case HandleKind.FieldDefinition: + ProcessFieldDef((FieldDefinitionHandle)handle, dllReader, pdbReader, docList); + break; + case HandleKind.PropertyDefinition: + ProcessPropertyDef((PropertyDefinitionHandle)handle, dllReader, pdbReader, docList); + break; + case HandleKind.EventDefinition: + ProcessEventDef((EventDefinitionHandle)handle, dllReader, pdbReader, docList); + break; + } + + return docList; + } + + private static void ProcessMethodDef(MethodDefinitionHandle methodDefHandle, MetadataReader dllReader, MetadataReader pdbReader, HashSet docList, bool processDeclaringType) + { + var mdi = pdbReader.GetMethodDebugInformation(methodDefHandle); + if (!mdi.Document.IsNil) + { + docList.Add(mdi.Document); + return; + } + + if (!mdi.SequencePointsBlob.IsNil) + { + foreach (var point in mdi.GetSequencePoints()) + { + if (!point.Document.IsNil) + { + docList.Add(point.Document); + // No need to check the type if we found a document + processDeclaringType = false; + } + } + } + + // Not all methods have document info, for example synthesized constructors, so we also want + // to get any documents from the declaring type + if (processDeclaringType) + { + var methodDef = dllReader.GetMethodDefinition(methodDefHandle); + var typeDefHandle = methodDef.GetDeclaringType(); + ProcessTypeDef(typeDefHandle, dllReader, pdbReader, docList); + } + } + + private static void ProcessEventDef(EventDefinitionHandle eventDefHandle, MetadataReader dllReader, MetadataReader pdbReader, HashSet docList) + { + var eventDef = dllReader.GetEventDefinition(eventDefHandle); + var accessors = eventDef.GetAccessors(); + if (!accessors.Adder.IsNil) + { + ProcessMethodDef(accessors.Adder, dllReader, pdbReader, docList, processDeclaringType: true); + } + + if (!accessors.Remover.IsNil) + { + ProcessMethodDef(accessors.Remover, dllReader, pdbReader, docList, processDeclaringType: true); + } + + if (!accessors.Raiser.IsNil) + { + ProcessMethodDef(accessors.Raiser, dllReader, pdbReader, docList, processDeclaringType: true); + } + + foreach (var other in accessors.Others) + { + ProcessMethodDef(other, dllReader, pdbReader, docList, processDeclaringType: true); + } + } + + private static void ProcessPropertyDef(PropertyDefinitionHandle propertyDefHandle, MetadataReader dllReader, MetadataReader pdbReader, HashSet docList) + { + var propertyDef = dllReader.GetPropertyDefinition(propertyDefHandle); + var accessors = propertyDef.GetAccessors(); + if (!accessors.Getter.IsNil) + { + ProcessMethodDef(accessors.Getter, dllReader, pdbReader, docList, processDeclaringType: true); + } + + if (!accessors.Setter.IsNil) + { + ProcessMethodDef(accessors.Setter, dllReader, pdbReader, docList, processDeclaringType: true); + } + + foreach (var other in accessors.Others) + { + ProcessMethodDef(other, dllReader, pdbReader, docList, processDeclaringType: true); + } + } + + private static void ProcessFieldDef(FieldDefinitionHandle fieldDefHandle, MetadataReader dllReader, MetadataReader pdbReader, HashSet docList) + { + var fieldDef = dllReader.GetFieldDefinition(fieldDefHandle); + var typeDefHandle = fieldDef.GetDeclaringType(); + ProcessTypeDef(typeDefHandle, dllReader, pdbReader, docList); + } + + private static void ProcessTypeDef(TypeDefinitionHandle typeDefHandle, MetadataReader dllReader, MetadataReader pdbReader, HashSet docList, bool processContainingType = true) + { + AddDocumentsFromTypeDefinitionDocuments(typeDefHandle, pdbReader, docList); + + // We don't necessarily have all of the documents associated with the type + var typeDef = dllReader.GetTypeDefinition(typeDefHandle); + foreach (var methodDefHandle in typeDef.GetMethods()) + { + ProcessMethodDef(methodDefHandle, dllReader, pdbReader, docList, processDeclaringType: false); + } + + if (processContainingType && typeDef.IsNested) + { + // If this is a nested type, then we want to check the outer type too + var containingType = typeDef.GetDeclaringType(); + if (!containingType.IsNil) + { + ProcessTypeDef(containingType, dllReader, pdbReader, docList); + } + } + + // And of course if this is an outer type, the only document info might be from methods in + // nested types + var nestedTypes = typeDef.GetNestedTypes(); + foreach (var nestedType in nestedTypes) + { + ProcessTypeDef(nestedType, dllReader, pdbReader, docList, processContainingType: false); + } + } + + private static void AddDocumentsFromTypeDefinitionDocuments(TypeDefinitionHandle typeDefHandle, MetadataReader pdbReader, HashSet docList) + { + var handles = pdbReader.GetCustomDebugInformation(typeDefHandle); + foreach (var cdiHandle in handles) + { + var cdi = pdbReader.GetCustomDebugInformation(cdiHandle); + var guid = pdbReader.GetGuid(cdi.Kind); + if (guid == PortableCustomDebugInfoKinds.TypeDefinitionDocuments) + { + if (((TypeDefinitionHandle)cdi.Parent).Equals(typeDefHandle)) + { + var reader = pdbReader.GetBlobReader(cdi.Value); + while (reader.RemainingBytes > 0) + { + docList.Add(MetadataTokens.DocumentHandle(reader.ReadCompressedInteger())); + } + } + } + } + } + } +} diff --git a/src/Microsoft.DocAsCode.Dotnet/SymbolFormatter.cs b/src/Microsoft.DocAsCode.Dotnet/SymbolFormatter.cs index 91a2315248b..7b9ea57044d 100644 --- a/src/Microsoft.DocAsCode.Dotnet/SymbolFormatter.cs +++ b/src/Microsoft.DocAsCode.Dotnet/SymbolFormatter.cs @@ -108,7 +108,7 @@ public static ImmutableArray GetSyntaxParts(ISymbol symbol, S } } - public static List ToLinkItems(this ImmutableArray parts, SyntaxLanguage language, bool overload) + public static List ToLinkItems(this ImmutableArray parts, Compilation compilation, SyntaxLanguage language, bool overload) { var result = new List(); foreach (var part in parts) @@ -133,7 +133,7 @@ LinkItem ToLinkItem(SymbolDisplayPart part) { Name = overload ? VisitorHelper.GetOverloadId(symbol) : VisitorHelper.GetId(symbol), DisplayName = part.ToString(), - Href = SymbolUrlResolver.GetSymbolUrl(symbol), + Href = SymbolUrlResolver.GetSymbolUrl(symbol, compilation), IsExternalPath = symbol.IsExtern || symbol.DeclaringSyntaxReferences.Length == 0, }; } diff --git a/src/Microsoft.DocAsCode.Dotnet/SymbolHelper.cs b/src/Microsoft.DocAsCode.Dotnet/SymbolHelper.cs index b324904e482..dbbfbb6b6a3 100644 --- a/src/Microsoft.DocAsCode.Dotnet/SymbolHelper.cs +++ b/src/Microsoft.DocAsCode.Dotnet/SymbolHelper.cs @@ -9,7 +9,7 @@ internal static class SymbolHelper public static MetadataItem? GenerateMetadataItem(this IAssemblySymbol assembly, Compilation compilation, ExtractMetadataConfig? config = null, DotnetApiOptions? options = null, IMethodSymbol[]? extensionMethods = null) { config ??= new(); - return assembly.Accept(new SymbolVisitorAdapter(compilation, new YamlModelGenerator(), config, new(config, options ?? new()), extensionMethods)); + return assembly.Accept(new SymbolVisitorAdapter(compilation, new(compilation), config, new(config, options ?? new()), extensionMethods)); } public static bool IsInstanceInterfaceMember(this ISymbol symbol) diff --git a/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.SourceLink.cs b/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.SourceLink.cs new file mode 100644 index 00000000000..43f1ffafa2d --- /dev/null +++ b/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.SourceLink.cs @@ -0,0 +1,121 @@ +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Debugging; +using Microsoft.CodeAnalysis.PdbSourceDocument; +using Microsoft.DocAsCode.Common; +using Microsoft.DocAsCode.Common.Git; +using Microsoft.SourceLink.Tools; + +#nullable enable + +namespace Microsoft.DocAsCode.Dotnet; + +internal static partial class SymbolUrlResolver +{ + private static readonly ConditionalWeakTable s_sourceLinkProviders = new(); + + public static string? GetPdbSourceLinkUrl(Compilation compilation, ISymbol symbol) + { + var assembly = symbol.ContainingAssembly; + if (assembly is null || assembly.Locations.Length == 0 || !assembly.Locations[0].IsInMetadata) + return null; + + var rawUrl = s_sourceLinkProviders.GetValue(assembly, CreateSourceLinkProvider)?.TryGetSourceLinkUrl(symbol); + + return rawUrl is null ? null : GitUtility.RawContentUrlToContentUrl(rawUrl); + + SourceLinkProvider? CreateSourceLinkProvider(IAssemblySymbol assembly) + { + var pe = compilation.GetMetadataReference(assembly) as PortableExecutableReference; + if (string.IsNullOrEmpty(pe?.FilePath) || !File.Exists(pe.FilePath)) + return null; + + var pdbPath = Path.ChangeExtension(pe.FilePath, ".pdb"); + if (!File.Exists(pdbPath)) + { + Logger.LogVerbose($"No PDF file found for {pe.FilePath}, skip loading source link."); + return null; + } + + return new( + new PEReader(File.OpenRead(pe.FilePath)), + MetadataReaderProvider.FromPortablePdbStream(File.OpenRead(pdbPath))); + } + } + + private class SourceLinkProvider : IDisposable + { + private readonly PEReader _peReader; + private readonly MetadataReaderProvider _pdbReaderProvider; + private readonly MetadataReader _dllReader; + private readonly MetadataReader _pdbReader; + + public SourceLinkProvider(PEReader peReader, MetadataReaderProvider pdbReaderProvider) + { + _peReader = peReader; + _pdbReaderProvider = pdbReaderProvider; + _dllReader = peReader.GetMetadataReader(); + _pdbReader = pdbReaderProvider.GetMetadataReader(); + } + + public string? TryGetSourceLinkUrl(ISymbol symbol) + { + var entityHandle = MetadataTokens.EntityHandle(symbol.MetadataToken); + var documentHandles = SymbolSourceDocumentFinder.FindDocumentHandles(entityHandle, _dllReader, _pdbReader); + var sourceLinkUrls = new List(); + + foreach (var handle in documentHandles) + { + if (TryGetSourceLinkUrl(handle) is { } sourceLinkUrl) + sourceLinkUrls.Add(sourceLinkUrl); + } + + return sourceLinkUrls.OrderBy(_ => _).FirstOrDefault(); + } + + private string? TryGetSourceLinkUrl(DocumentHandle handle) + { + var document = _pdbReader.GetDocument(handle); + if (document.Name.IsNil) + return null; + + var documentName = _pdbReader.GetString(document.Name); + if (documentName is null) + return null; + + foreach (var cdiHandle in _pdbReader.GetCustomDebugInformation(EntityHandle.ModuleDefinition)) + { + var cdi = _pdbReader.GetCustomDebugInformation(cdiHandle); + if (_pdbReader.GetGuid(cdi.Kind) == PortableCustomDebugInfoKinds.SourceLink && !cdi.Value.IsNil) + { + var blobReader = _pdbReader.GetBlobReader(cdi.Value); + var sourceLinkJson = blobReader.ReadUTF8(blobReader.Length); + + var map = SourceLinkMap.Parse(sourceLinkJson); + + if (map.TryGetUri(documentName, out var uri)) + { + return uri; + } + } + } + + return null; + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _peReader.Dispose(); + _pdbReaderProvider.Dispose(); + } + + ~SourceLinkProvider() + { + Dispose(); + } + } +} diff --git a/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.cs b/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.cs index c64edd7ddcd..22fda2c32d8 100644 --- a/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.cs +++ b/src/Microsoft.DocAsCode.Dotnet/SymbolUrlResolver.cs @@ -6,8 +6,9 @@ namespace Microsoft.DocAsCode.Dotnet; internal static partial class SymbolUrlResolver { - public static string? GetSymbolUrl(ISymbol symbol) + public static string? GetSymbolUrl(ISymbol symbol, Compilation compilation) { - return GetMicrosoftLearnUrl(symbol); + return GetMicrosoftLearnUrl(symbol) + ?? GetPdbSourceLinkUrl(compilation, symbol); } } diff --git a/src/Microsoft.DocAsCode.Dotnet/Visitors/SymbolVisitorAdapter.cs b/src/Microsoft.DocAsCode.Dotnet/Visitors/SymbolVisitorAdapter.cs index a9170ff0ab0..0893cc71fc7 100644 --- a/src/Microsoft.DocAsCode.Dotnet/Visitors/SymbolVisitorAdapter.cs +++ b/src/Microsoft.DocAsCode.Dotnet/Visitors/SymbolVisitorAdapter.cs @@ -49,7 +49,7 @@ public override MetadataItem DefaultVisit(ISymbol symbol) item.DisplayNames = new SortedList(); item.DisplayNamesWithType = new SortedList(); item.DisplayQualifiedNames = new SortedList(); - item.Source = VisitorHelper.GetSourceDetail(symbol); + item.Source = VisitorHelper.GetSourceDetail(symbol, _compilation); var assemblyName = symbol.ContainingAssembly?.Name; item.AssemblyNameList = string.IsNullOrEmpty(assemblyName) ? null : new List { assemblyName }; if (!(symbol is INamespaceSymbol)) diff --git a/src/Microsoft.DocAsCode.Dotnet/Visitors/VisitorHelper.cs b/src/Microsoft.DocAsCode.Dotnet/Visitors/VisitorHelper.cs index 9fdf68f4e10..2be35d3bd6d 100644 --- a/src/Microsoft.DocAsCode.Dotnet/Visitors/VisitorHelper.cs +++ b/src/Microsoft.DocAsCode.Dotnet/Visitors/VisitorHelper.cs @@ -148,7 +148,7 @@ public static ApiParameter GetTypeParameterDescription(ITypeParameterSymbol symb }; } - public static SourceDetail GetSourceDetail(ISymbol symbol) + public static SourceDetail GetSourceDetail(ISymbol symbol, Compilation compilation) { // For namespace, definition is meaningless if (symbol == null || symbol.Kind == SymbolKind.Namespace) @@ -159,11 +159,12 @@ public static SourceDetail GetSourceDetail(ISymbol symbol) var syntaxRef = symbol.DeclaringSyntaxReferences.LastOrDefault(); if (symbol.IsExtern || syntaxRef == null) { - return new SourceDetail + if (SymbolUrlResolver.GetPdbSourceLinkUrl(compilation, symbol) is string url) { - IsExternalPath = true, - Path = symbol.ContainingAssembly?.Name, - }; + return new() { Href = url }; + } + + return null; } var syntaxNode = syntaxRef.GetSyntax(); diff --git a/src/Microsoft.DocAsCode.Dotnet/Visitors/YamlModelGenerator.cs b/src/Microsoft.DocAsCode.Dotnet/Visitors/YamlModelGenerator.cs index 4cb43f42ebb..2fd38359821 100644 --- a/src/Microsoft.DocAsCode.Dotnet/Visitors/YamlModelGenerator.cs +++ b/src/Microsoft.DocAsCode.Dotnet/Visitors/YamlModelGenerator.cs @@ -8,6 +8,13 @@ namespace Microsoft.DocAsCode.Dotnet; internal class YamlModelGenerator { + private readonly Compilation _compilation; + + public YamlModelGenerator(Compilation compilation) + { + _compilation = compilation; + } + public void DefaultVisit(ISymbol symbol, MetadataItem item) { item.DisplayNames[SyntaxLanguage.CSharp] = SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp); @@ -28,9 +35,9 @@ public void GenerateReference(ISymbol symbol, ReferenceItem reference, bool asOv if (!reference.QualifiedNameParts.ContainsKey(SyntaxLanguage.CSharp)) reference.QualifiedNameParts.Add(SyntaxLanguage.CSharp, new()); - reference.NameParts[SyntaxLanguage.CSharp] = SymbolFormatter.GetNameParts(symbol, SyntaxLanguage.CSharp, nullableReferenceType: false, asOverload).ToLinkItems(SyntaxLanguage.CSharp, asOverload); - reference.NameWithTypeParts[SyntaxLanguage.CSharp] = SymbolFormatter.GetNameWithTypeParts(symbol, SyntaxLanguage.CSharp, nullableReferenceType: false, asOverload).ToLinkItems(SyntaxLanguage.CSharp, asOverload); - reference.QualifiedNameParts[SyntaxLanguage.CSharp] = SymbolFormatter.GetQualifiedNameParts(symbol, SyntaxLanguage.CSharp, nullableReferenceType: false, asOverload).ToLinkItems(SyntaxLanguage.CSharp, asOverload); + reference.NameParts[SyntaxLanguage.CSharp] = SymbolFormatter.GetNameParts(symbol, SyntaxLanguage.CSharp, nullableReferenceType: false, asOverload).ToLinkItems(_compilation, SyntaxLanguage.CSharp, asOverload); + reference.NameWithTypeParts[SyntaxLanguage.CSharp] = SymbolFormatter.GetNameWithTypeParts(symbol, SyntaxLanguage.CSharp, nullableReferenceType: false, asOverload).ToLinkItems(_compilation, SyntaxLanguage.CSharp, asOverload); + reference.QualifiedNameParts[SyntaxLanguage.CSharp] = SymbolFormatter.GetQualifiedNameParts(symbol, SyntaxLanguage.CSharp, nullableReferenceType: false, asOverload).ToLinkItems(_compilation, SyntaxLanguage.CSharp, asOverload); if (!reference.NameParts.ContainsKey(SyntaxLanguage.VB)) reference.NameParts.Add(SyntaxLanguage.VB, new()); @@ -39,9 +46,9 @@ public void GenerateReference(ISymbol symbol, ReferenceItem reference, bool asOv if (!reference.QualifiedNameParts.ContainsKey(SyntaxLanguage.VB)) reference.QualifiedNameParts.Add(SyntaxLanguage.VB, new()); - reference.NameParts[SyntaxLanguage.VB] = SymbolFormatter.GetNameParts(symbol, SyntaxLanguage.VB, nullableReferenceType: false, asOverload).ToLinkItems(SyntaxLanguage.VB, asOverload); - reference.NameWithTypeParts[SyntaxLanguage.VB] = SymbolFormatter.GetNameWithTypeParts(symbol, SyntaxLanguage.VB, nullableReferenceType: false, asOverload).ToLinkItems(SyntaxLanguage.VB, asOverload); - reference.QualifiedNameParts[SyntaxLanguage.VB] = SymbolFormatter.GetQualifiedNameParts(symbol, SyntaxLanguage.VB, nullableReferenceType: false, asOverload).ToLinkItems(SyntaxLanguage.VB, asOverload); + reference.NameParts[SyntaxLanguage.VB] = SymbolFormatter.GetNameParts(symbol, SyntaxLanguage.VB, nullableReferenceType: false, asOverload).ToLinkItems(_compilation, SyntaxLanguage.VB, asOverload); + reference.NameWithTypeParts[SyntaxLanguage.VB] = SymbolFormatter.GetNameWithTypeParts(symbol, SyntaxLanguage.VB, nullableReferenceType: false, asOverload).ToLinkItems(_compilation, SyntaxLanguage.VB, asOverload); + reference.QualifiedNameParts[SyntaxLanguage.VB] = SymbolFormatter.GetQualifiedNameParts(symbol, SyntaxLanguage.VB, nullableReferenceType: false, asOverload).ToLinkItems(_compilation, SyntaxLanguage.VB, asOverload); } public void GenerateSyntax(ISymbol symbol, SyntaxDetail syntax, SymbolFilter filter) diff --git a/templates/common/common.js b/templates/common/common.js index 59293012c26..8fe80500ce2 100644 --- a/templates/common/common.js +++ b/templates/common/common.js @@ -31,7 +31,9 @@ function getHtmlId(input) { // Note: the parameter `gitContribute` won't be used in this function function getViewSourceHref(item, gitContribute, gitUrlPattern) { - if (!item || !item.source || !item.source.remote) return ''; + if (!item || !item.source) return ''; + if (item.source.href) return item.source.href; + if (!item.source.remote) return ''; return getRemoteUrl(item.source.remote, item.source.startLine - '0' + 1, null, gitUrlPattern); } diff --git a/test/Microsoft.DocAsCode.Dotnet.Tests/SymbolUrlResolverUnitTest.cs b/test/Microsoft.DocAsCode.Dotnet.Tests/SymbolUrlResolverUnitTest.cs index d5ca80d981e..47870a91f9e 100644 --- a/test/Microsoft.DocAsCode.Dotnet.Tests/SymbolUrlResolverUnitTest.cs +++ b/test/Microsoft.DocAsCode.Dotnet.Tests/SymbolUrlResolverUnitTest.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; using Xunit; namespace Microsoft.DocAsCode.Dotnet.Tests; @@ -73,4 +75,27 @@ public static void GetUrlFragmentFromUidTest(string uid, string expectedFragment { Assert.Equal(expectedFragment, SymbolUrlResolver.GetUrlFragmentFromUid(uid)); } + + [Fact] + public static void GetPdbSourceLinkUrlTest() + { + var (compilation, assembly) = CompilationHelper.CreateCompilationFromAssembly($"{typeof(DotnetApiCatalog).Assembly.GetName().Name}.dll"); + + var type = assembly.GetTypeByMetadataName(typeof(DotnetApiCatalog).FullName); + Assert.NotNull(type); + Assert.Equal( + "https://github.com/dotnet/docfx/blob/*/src/Microsoft.DocAsCode.Dotnet/DotnetApiCatalog.cs", + ReplaceSHA(SymbolUrlResolver.GetPdbSourceLinkUrl(compilation, type))); + + var method = type.GetMembers(nameof(DotnetApiCatalog.GenerateManagedReferenceYamlFiles)).FirstOrDefault(); + Assert.NotNull(method); + Assert.Equal( + "https://github.com/dotnet/docfx/blob/*/src/Microsoft.DocAsCode.Dotnet/DotnetApiCatalog.cs", + ReplaceSHA(SymbolUrlResolver.GetPdbSourceLinkUrl(compilation, method))); + + static string ReplaceSHA(string value) + { + return Regex.Replace(value, "\\/[0-9a-zA-Z]{40}\\/", "/*/"); + } + } } diff --git a/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.Class1.html.view.verified.json b/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.Class1.html.view.verified.json index 5bb5a90c9fa..425f9cc1757 100644 --- a/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.Class1.html.view.verified.json +++ b/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.Class1.html.view.verified.json @@ -114,10 +114,10 @@ ] }, "source": { - "path": "BuildFromAssembly", + "href": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "startLine": 0.0, "endLine": 0.0, - "isExternal": true + "isExternal": false }, "assemblies": [ "BuildFromAssembly" @@ -172,7 +172,7 @@ "summary": "", "platform": null, "docurl": "", - "sourceurl": "", + "sourceurl": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "remarks": "", "conceptual": "", "implements": "", @@ -248,10 +248,10 @@ ] }, "source": { - "path": "BuildFromAssembly", + "href": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "startLine": 0.0, "endLine": 0.0, - "isExternal": true + "isExternal": false }, "assemblies": [ "BuildFromAssembly" @@ -306,7 +306,7 @@ "summary": "", "platform": null, "docurl": "", - "sourceurl": "", + "sourceurl": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "remarks": "", "conceptual": "", "implements": "", @@ -355,10 +355,10 @@ ], "type": "class", "source": { - "path": "BuildFromAssembly", + "href": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "startLine": 0.0, "endLine": 0.0, - "isExternal": true + "isExternal": false }, "assemblies": [ "BuildFromAssembly" @@ -837,7 +837,7 @@ "_tocRel": "toc.html", "yamlmime": "ManagedReference", "docurl": "", - "sourceurl": "", + "sourceurl": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "remarks": "", "conceptual": "", "implements": "", diff --git a/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.html.view.verified.json b/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.html.view.verified.json index a4ec07f01d9..e3649d9342b 100644 --- a/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.html.view.verified.json +++ b/test/docfx.Snapshot.Tests/SamplesTest.Seed/api/BuildFromAssembly.html.view.verified.json @@ -12,6 +12,7 @@ "uid": "BuildFromAssembly.Class1", "isExtensionMethod": false, "isExternal": true, + "href": "https://github.com/dotnet/docfx/blob/main/samples/seed/dotnet/assembly/Class1.cs", "name": [ { "lang": "csharp",