Skip to content

Commit 082f8b0

Browse files
committed
Emit symbol URL to link to declaration in repo
rdar://88537303
1 parent 284dfa3 commit 082f8b0

File tree

18 files changed

+694
-44
lines changed

18 files changed

+694
-44
lines changed

Sources/SwiftDocC/Converter/DocumentationContextConverter.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ public class DocumentationContextConverter {
3737
/// Whether the documentation converter should include access level information for symbols.
3838
let shouldEmitSymbolAccessLevels: Bool
3939

40+
/// The remote source control repository where the documented module's source is hosted.
41+
let sourceRepository: SourceRepository?
42+
4043
/// Creates a new node converter for the given bundle and context.
4144
///
4245
/// The converter uses bundle and context to resolve references to other documentation and describe the documentation hierarchy.
@@ -51,18 +54,21 @@ public class DocumentationContextConverter {
5154
/// Before passing `true` please confirm that your use case doesn't include public
5255
/// distribution of any created render nodes as there are filesystem privacy and security
5356
/// concerns with distributing this data.
57+
/// - sourceRepository: The source repository where the documentation's sources are hosted.
5458
public init(
5559
bundle: DocumentationBundle,
5660
context: DocumentationContext,
5761
renderContext: RenderContext,
5862
emitSymbolSourceFileURIs: Bool = false,
59-
emitSymbolAccessLevels: Bool = false
63+
emitSymbolAccessLevels: Bool = false,
64+
sourceRepository: SourceRepository? = nil
6065
) {
6166
self.bundle = bundle
6267
self.context = context
6368
self.renderContext = renderContext
6469
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
6570
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
71+
self.sourceRepository = sourceRepository
6672
}
6773

6874
/// Converts a documentation node to a render node.
@@ -84,7 +90,8 @@ public class DocumentationContextConverter {
8490
source: source,
8591
renderContext: renderContext,
8692
emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs,
87-
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels
93+
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels,
94+
sourceRepository: sourceRepository
8895
)
8996
return translator.visit(node.semantic) as? RenderNode
9097
}

Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
8989
/// Whether the documentation converter should include access level information for symbols.
9090
var shouldEmitSymbolAccessLevels: Bool
9191

92+
/// The source repository where the documentation's sources are hosted.
93+
var sourceRepository: SourceRepository?
94+
9295
/// `true` if the conversion is cancelled.
9396
private var isCancelled: Synchronized<Bool>? = nil
9497

@@ -128,6 +131,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
128131
bundleDiscoveryOptions: BundleDiscoveryOptions,
129132
emitSymbolSourceFileURIs: Bool = false,
130133
emitSymbolAccessLevels: Bool = false,
134+
sourceRepository: SourceRepository? = nil,
131135
isCancelled: Synchronized<Bool>? = nil,
132136
diagnosticEngine: DiagnosticEngine = .init()
133137
) {
@@ -142,6 +146,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
142146
self.bundleDiscoveryOptions = bundleDiscoveryOptions
143147
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
144148
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
149+
self.sourceRepository = sourceRepository
145150
self.isCancelled = isCancelled
146151
self.diagnosticEngine = diagnosticEngine
147152

@@ -247,7 +252,8 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
247252
context: context,
248253
renderContext: renderContext,
249254
emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs,
250-
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels
255+
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels,
256+
sourceRepository: sourceRepository
251257
)
252258

253259
var indexingRecords = [IndexingRecord]()

Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ public struct RenderMetadata: VariantContainer {
144144
/// The variants for the source file URI of a page.
145145
public var sourceFileURIVariants: VariantCollection<String?> = .init(defaultValue: nil)
146146

147+
/// The remote location where the source declaration of the topic can be viewed.
148+
public var remoteSource: RemoteSource? {
149+
get { getVariantDefaultValue(keyPath: \.remoteSourceVariants) }
150+
set { setVariantDefaultValue(newValue, keyPath: \.remoteSourceVariants) }
151+
}
152+
153+
/// The variants for the topic's remote source.
154+
public var remoteSourceVariants: VariantCollection<RemoteSource?> = .init(defaultValue: nil)
155+
147156
/// Any tags assigned to the node.
148157
public var tags: [RenderNode.Tag]?
149158
}
@@ -163,6 +172,21 @@ extension RenderMetadata: Codable {
163172
/// but have no authoring support at the moment.
164173
public let relatedModules: [String]?
165174
}
175+
176+
/// Describes the location of the topic's source code, hosted remotely by a source service.
177+
public struct RemoteSource: Codable, Equatable {
178+
/// The name of the file where the topic is declared.
179+
public var fileName: String
180+
181+
/// The location of the topic's source code, hosted by a source service.
182+
public var url: URL
183+
184+
/// Creates a topic's source given its source code's file name and URL.
185+
public init(fileName: String, url: URL) {
186+
self.fileName = fileName
187+
self.url = url
188+
}
189+
}
166190

167191
public struct CodingKeys: CodingKey, Hashable {
168192
public var stringValue: String
@@ -196,6 +220,7 @@ extension RenderMetadata: Codable {
196220
public static let fragments = CodingKeys(stringValue: "fragments")
197221
public static let navigatorTitle = CodingKeys(stringValue: "navigatorTitle")
198222
public static let sourceFileURI = CodingKeys(stringValue: "sourceFileURI")
223+
public static let remoteSource = CodingKeys(stringValue: "remoteSource")
199224
public static let tags = CodingKeys(stringValue: "tags")
200225
}
201226

@@ -221,6 +246,7 @@ extension RenderMetadata: Codable {
221246
fragmentsVariants = try container.decodeVariantCollectionIfPresent(ofValueType: [DeclarationRenderSection.Token]?.self, forKey: .fragments)
222247
navigatorTitleVariants = try container.decodeVariantCollectionIfPresent(ofValueType: [DeclarationRenderSection.Token]?.self, forKey: .navigatorTitle)
223248
sourceFileURIVariants = try container.decodeVariantCollectionIfPresent(ofValueType: String?.self, forKey: .sourceFileURI)
249+
remoteSourceVariants = try container.decodeVariantCollectionIfPresent(ofValueType: RemoteSource?.self, forKey: .remoteSource)
224250
tags = try container.decodeIfPresent([RenderNode.Tag].self, forKey: .tags)
225251

226252
let extraKeys = Set(container.allKeys).subtracting(
@@ -242,6 +268,7 @@ extension RenderMetadata: Codable {
242268
.fragments,
243269
.navigatorTitle,
244270
.sourceFileURI,
271+
.remoteSource,
245272
.tags
246273
]
247274
)
@@ -272,6 +299,7 @@ extension RenderMetadata: Codable {
272299
try container.encodeVariantCollection(fragmentsVariants, forKey: .fragments, encoder: encoder)
273300
try container.encodeVariantCollection(navigatorTitleVariants, forKey: .navigatorTitle, encoder: encoder)
274301
try container.encodeVariantCollection(sourceFileURIVariants, forKey: .sourceFileURI, encoder: encoder)
302+
try container.encodeVariantCollection(remoteSourceVariants, forKey: .remoteSource, encoder: encoder)
275303
if let tags = self.tags, !tags.isEmpty {
276304
try container.encodeIfPresent(tags, forKey: .tags)
277305
}

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public struct RenderNodeTranslator: SemanticVisitor {
4545
/// Whether the documentation converter should include access level information for symbols.
4646
var shouldEmitSymbolAccessLevels: Bool
4747

48+
/// The source repository where the documentation's sources are hosted.
49+
var sourceRepository: SourceRepository?
50+
4851
public mutating func visitCode(_ code: Code) -> RenderTree? {
4952
let fileType = NSString(string: code.fileName).pathExtension
5053
let fileReference = code.fileReference
@@ -1157,6 +1160,26 @@ public struct RenderNodeTranslator: SemanticVisitor {
11571160
} ?? .init(defaultValue: nil)
11581161
}
11591162

1163+
if let sourceRepository = sourceRepository {
1164+
node.metadata.remoteSourceVariants = VariantCollection<RenderMetadata.RemoteSource?>(
1165+
from: symbol.locationVariants
1166+
) { _, location in
1167+
guard let locationURL = location.url(),
1168+
let url = sourceRepository.format(
1169+
sourceFileURL: locationURL,
1170+
lineNumber: location.position.line + 1
1171+
)
1172+
else {
1173+
return nil
1174+
}
1175+
1176+
return RenderMetadata.RemoteSource(
1177+
fileName: locationURL.lastPathComponent,
1178+
url: url
1179+
)
1180+
} ?? .init(defaultValue: nil)
1181+
}
1182+
11601183
if shouldEmitSymbolAccessLevels {
11611184
node.metadata.symbolAccessLevelVariants = VariantCollection<String?>(from: symbol.accessLevelVariants)
11621185
}
@@ -1553,7 +1576,8 @@ public struct RenderNodeTranslator: SemanticVisitor {
15531576
source: URL?,
15541577
renderContext: RenderContext? = nil,
15551578
emitSymbolSourceFileURIs: Bool = false,
1556-
emitSymbolAccessLevels: Bool = false
1579+
emitSymbolAccessLevels: Bool = false,
1580+
sourceRepository: SourceRepository? = nil
15571581
) {
15581582
self.context = context
15591583
self.bundle = bundle
@@ -1563,6 +1587,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
15631587
self.contentRenderer = DocumentationContentRenderer(documentationContext: context, bundle: bundle)
15641588
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
15651589
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
1590+
self.sourceRepository = sourceRepository
15661591
}
15671592
}
15681593

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
13+
/// A remote repository that hosts source code.
14+
public struct SourceRepository {
15+
/// The path at which the repository is cloned locally.
16+
public var checkoutPath: String
17+
18+
/// The base URL where the service hosts the repository's contents.
19+
public var sourceServiceBaseURL: URL
20+
21+
/// A function that formats a line number to be included in a URL.
22+
public var formatLineNumber: (Int) -> String
23+
24+
/// Creates a source code repository.
25+
/// - Parameters:
26+
/// - checkoutPath: The path at which the repository is checked out locally and from which its symbol graphs were generated.
27+
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
28+
/// - formatLineNumber: A function that formats a line number to be included in a URL.
29+
public init(
30+
checkoutPath: String,
31+
sourceServiceBaseURL: URL,
32+
formatLineNumber: @escaping (Int) -> String
33+
) {
34+
self.checkoutPath = checkoutPath
35+
self.sourceServiceBaseURL = sourceServiceBaseURL
36+
self.formatLineNumber = formatLineNumber
37+
}
38+
39+
/// Formats a local source file URL to a URL hosted by the remote source code service.
40+
/// - Parameters:
41+
/// - sourceFileURL: The location of the source file on disk.
42+
/// - lineNumber: A line number in the source file, 1-indexed.
43+
/// - Returns: The URL of the file hosted by the remote source code service if it could be constructed, otherwise, `nil`.
44+
public func format(sourceFileURL: URL, lineNumber: Int? = nil) -> URL? {
45+
guard sourceFileURL.path.hasPrefix(checkoutPath) else {
46+
return nil
47+
}
48+
49+
let path = sourceFileURL.path.dropFirst(checkoutPath.count).removingLeadingSlash
50+
return sourceServiceBaseURL
51+
.appendingPathComponent(path)
52+
.withFragment(lineNumber.map(formatLineNumber))
53+
}
54+
}
55+
56+
public extension SourceRepository {
57+
/// Creates a source repository hosted by the GitHub service.
58+
/// - Parameters:
59+
/// - checkoutPath: The path of the local checkout.
60+
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
61+
static func github(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
62+
SourceRepository(
63+
checkoutPath: checkoutPath,
64+
sourceServiceBaseURL: sourceServiceBaseURL,
65+
formatLineNumber: { line in "L\(line)" }
66+
)
67+
}
68+
69+
/// Creates a source repository hosted by the GitLab service.
70+
/// - Parameters:
71+
/// - checkoutPath: The path of the local checkout.
72+
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
73+
static func gitlab(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
74+
SourceRepository(
75+
checkoutPath: checkoutPath,
76+
sourceServiceBaseURL: sourceServiceBaseURL,
77+
formatLineNumber: { line in "L\(line)" }
78+
)
79+
}
80+
81+
/// Creates a source repository hosted by the BitBucket service.
82+
/// - Parameters:
83+
/// - checkoutPath: The path of the local checkout.
84+
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
85+
static func bitbucket(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
86+
SourceRepository(
87+
checkoutPath: checkoutPath,
88+
sourceServiceBaseURL: sourceServiceBaseURL,
89+
formatLineNumber: { line in "lines-\(line)" }
90+
)
91+
}
92+
}

Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,21 @@
20482048
"sourceFileURI": {
20492049
"type": "string"
20502050
},
2051+
"remoteSource": {
2052+
"type": "object",
2053+
"required": [
2054+
"fileName",
2055+
"url"
2056+
],
2057+
"properties": {
2058+
"fileName": {
2059+
"type": "string"
2060+
},
2061+
"url": {
2062+
"type": "string"
2063+
}
2064+
}
2065+
},
20512066
"tags": {
20522067
"type": "array",
20532068
"items": {

Sources/SwiftDocC/Utility/FoundationExtensions/String+Path.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@
1010

1111
import Foundation
1212

13-
extension String {
13+
extension StringProtocol {
1414
/// A copy of the string prefixed with a slash ("/") if the string doesn't already start with a leading slash.
1515
var prependingLeadingSlash: String {
16-
guard !hasPrefix("/") else { return self }
16+
guard !hasPrefix("/") else { return String(self) }
1717
return "/".appending(self)
1818
}
1919

2020
/// A copy of the string without a leading slash ("/") or the original string if it doesn't start with a leading slash.
2121
var removingLeadingSlash: String {
22-
guard hasPrefix("/") else { return self }
22+
guard hasPrefix("/") else { return String(self) }
2323
return String(dropFirst())
2424
}
25+
26+
var removingTrailingSlash: String {
27+
guard hasSuffix("/") else { return String(self) }
28+
return String(dropLast())
29+
}
2530
}

Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public struct ConvertAction: Action, RecreatingContext {
4242
let transformForStaticHosting: Bool
4343
let hostingBasePath: String?
4444

45+
let sourceRepository: SourceRepository?
4546

4647
private(set) var context: DocumentationContext {
4748
didSet {
@@ -100,7 +101,8 @@ public struct ConvertAction: Action, RecreatingContext {
100101
inheritDocs: Bool = false,
101102
experimentalEnableCustomTemplates: Bool = false,
102103
transformForStaticHosting: Bool = false,
103-
hostingBasePath: String? = nil
104+
hostingBasePath: String? = nil,
105+
sourceRepository: SourceRepository? = nil
104106
) throws
105107
{
106108
self.rootURL = documentationBundleURL
@@ -117,6 +119,7 @@ public struct ConvertAction: Action, RecreatingContext {
117119
self.documentationCoverageOptions = documentationCoverageOptions
118120
self.transformForStaticHosting = transformForStaticHosting
119121
self.hostingBasePath = hostingBasePath
122+
self.sourceRepository = sourceRepository
120123

121124
let filterLevel: DiagnosticSeverity
122125
if analyze {
@@ -180,6 +183,7 @@ public struct ConvertAction: Action, RecreatingContext {
180183
context: self.context,
181184
dataProvider: dataProvider,
182185
bundleDiscoveryOptions: bundleDiscoveryOptions,
186+
sourceRepository: sourceRepository,
183187
isCancelled: isCancelled,
184188
diagnosticEngine: self.diagnosticEngine
185189
)
@@ -208,6 +212,7 @@ public struct ConvertAction: Action, RecreatingContext {
208212
experimentalEnableCustomTemplates: Bool = false,
209213
transformForStaticHosting: Bool,
210214
hostingBasePath: String?,
215+
sourceRepository: SourceRepository? = nil,
211216
temporaryDirectory: URL
212217
) throws {
213218
// Note: This public initializer exists separately from the above internal one
@@ -239,7 +244,8 @@ public struct ConvertAction: Action, RecreatingContext {
239244
inheritDocs: inheritDocs,
240245
experimentalEnableCustomTemplates: experimentalEnableCustomTemplates,
241246
transformForStaticHosting: transformForStaticHosting,
242-
hostingBasePath: hostingBasePath
247+
hostingBasePath: hostingBasePath,
248+
sourceRepository: sourceRepository
243249
)
244250
}
245251

0 commit comments

Comments
 (0)