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",