Skip to content

Commit 3d304d2

Browse files
committed
Emit symbol URL to link to declaration in repo
1 parent 41f2ac1 commit 3d304d2

File tree

20 files changed

+689
-11
lines changed

20 files changed

+689
-11
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 source repository where the documentation's sources are 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.
@@ -90,7 +96,8 @@ public class DocumentationContextConverter {
9096
source: source,
9197
renderContext: renderContext,
9298
emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs,
93-
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels
99+
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels,
100+
sourceRepository: sourceRepository
94101
)
95102
return translator.visit(node.semantic) as? RenderNode
96103
}

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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@ 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+
public var source: Source? {
148+
get { getVariantDefaultValue(keyPath: \.sourceVariants) }
149+
set { setVariantDefaultValue(newValue, keyPath: \.sourceVariants) }
150+
}
151+
152+
public var sourceVariants: VariantCollection<Source?> = .init(defaultValue: nil)
153+
147154
/// Any tags assigned to the node.
148155
public var tags: [RenderNode.Tag]?
149156
}
@@ -163,6 +170,20 @@ extension RenderMetadata: Codable {
163170
/// but have no authoring support at the moment.
164171
public let relatedModules: [String]?
165172
}
173+
174+
/// Describes the location of the topic's source code, hosted by a source service.
175+
public struct Source: Codable, Equatable {
176+
/// The file name of the topic's source code.
177+
public var fileName: String
178+
179+
/// The location of the topic's source code, hosted by a source service.
180+
public var url: URL
181+
182+
public init(fileName: String, url: URL) {
183+
self.fileName = fileName
184+
self.url = url
185+
}
186+
}
166187

167188
public struct CodingKeys: CodingKey, Hashable {
168189
public var stringValue: String
@@ -196,6 +217,7 @@ extension RenderMetadata: Codable {
196217
public static let fragments = CodingKeys(stringValue: "fragments")
197218
public static let navigatorTitle = CodingKeys(stringValue: "navigatorTitle")
198219
public static let sourceFileURI = CodingKeys(stringValue: "sourceFileURI")
220+
public static let source = CodingKeys(stringValue: "source")
199221
public static let tags = CodingKeys(stringValue: "tags")
200222
}
201223

@@ -221,6 +243,7 @@ extension RenderMetadata: Codable {
221243
fragmentsVariants = try container.decodeVariantCollectionIfPresent(ofValueType: [DeclarationRenderSection.Token]?.self, forKey: .fragments)
222244
navigatorTitleVariants = try container.decodeVariantCollectionIfPresent(ofValueType: [DeclarationRenderSection.Token]?.self, forKey: .navigatorTitle)
223245
sourceFileURIVariants = try container.decodeVariantCollectionIfPresent(ofValueType: String?.self, forKey: .sourceFileURI)
246+
sourceVariants = try container.decodeVariantCollectionIfPresent(ofValueType: Source?.self, forKey: .source)
224247
tags = try container.decodeIfPresent([RenderNode.Tag].self, forKey: .tags)
225248

226249
let extraKeys = Set(container.allKeys).subtracting(
@@ -242,6 +265,7 @@ extension RenderMetadata: Codable {
242265
.fragments,
243266
.navigatorTitle,
244267
.sourceFileURI,
268+
.source,
245269
.tags
246270
]
247271
)
@@ -272,6 +296,7 @@ extension RenderMetadata: Codable {
272296
try container.encodeVariantCollection(fragmentsVariants, forKey: .fragments, encoder: encoder)
273297
try container.encodeVariantCollection(navigatorTitleVariants, forKey: .navigatorTitle, encoder: encoder)
274298
try container.encodeVariantCollection(sourceFileURIVariants, forKey: .sourceFileURI, encoder: encoder)
299+
try container.encodeVariantCollection(sourceVariants, forKey: .source, encoder: encoder)
275300
if let tags = self.tags, !tags.isEmpty {
276301
try container.encodeIfPresent(tags, forKey: .tags)
277302
}

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 36 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
@@ -748,6 +751,16 @@ public struct RenderNodeTranslator: SemanticVisitor {
748751
return seeAlsoSections
749752
} ?? .init(defaultValue: [])
750753

754+
if let sourceRepository = sourceRepository,
755+
let documentURL = context.documentURL(for: identifier),
756+
let sourceURL = sourceRepository.format(sourceFileURL: documentURL)
757+
{
758+
node.metadata.source = RenderMetadata.Source(
759+
fileName: documentURL.lastPathComponent,
760+
url: sourceURL
761+
)
762+
}
763+
751764
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
752765
node.references = createTopicRenderReferences()
753766

@@ -1154,6 +1167,26 @@ public struct RenderNodeTranslator: SemanticVisitor {
11541167
} ?? .init(defaultValue: nil)
11551168
}
11561169

1170+
if let sourceRepository = sourceRepository {
1171+
node.metadata.sourceVariants = VariantCollection<RenderMetadata.Source?>(
1172+
from: symbol.locationVariants
1173+
) { _, location in
1174+
guard let locationURL = location.url(),
1175+
let url = sourceRepository.format(
1176+
sourceFileURL: locationURL,
1177+
lineNumber: location.position.line + 1
1178+
)
1179+
else {
1180+
return nil
1181+
}
1182+
1183+
return RenderMetadata.Source(
1184+
fileName: locationURL.lastPathComponent,
1185+
url: url
1186+
)
1187+
} ?? .init(defaultValue: nil)
1188+
}
1189+
11571190
if shouldEmitSymbolAccessLevels {
11581191
node.metadata.symbolAccessLevelVariants = VariantCollection<String?>(from: symbol.accessLevelVariants)
11591192
}
@@ -1555,7 +1588,8 @@ public struct RenderNodeTranslator: SemanticVisitor {
15551588
source: URL?,
15561589
renderContext: RenderContext? = nil,
15571590
emitSymbolSourceFileURIs: Bool = false,
1558-
emitSymbolAccessLevels: Bool = false
1591+
emitSymbolAccessLevels: Bool = false,
1592+
sourceRepository: SourceRepository? = nil
15591593
) {
15601594
self.context = context
15611595
self.bundle = bundle
@@ -1565,6 +1599,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
15651599
self.contentRenderer = DocumentationContentRenderer(documentationContext: context, bundle: bundle)
15661600
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
15671601
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
1602+
self.sourceRepository = sourceRepository
15681603
}
15691604
}
15701605

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
public struct SourceRepository {
14+
public var checkoutPath: String
15+
public var sourceServiceBaseURL: URL
16+
public var formatLineNumber: (Int) -> String
17+
18+
public init(
19+
checkoutPath: String,
20+
sourceServiceBaseURL: URL,
21+
formatLineNumber: @escaping (Int) -> String
22+
) {
23+
self.checkoutPath = checkoutPath
24+
self.sourceServiceBaseURL = sourceServiceBaseURL
25+
self.formatLineNumber = formatLineNumber
26+
}
27+
28+
public func format(sourceFileURL: URL, lineNumber: Int? = nil) -> URL? {
29+
guard sourceFileURL.path.hasPrefix(checkoutPath) else {
30+
return nil
31+
}
32+
33+
let path = sourceFileURL.path.dropFirst(checkoutPath.count).removingLeadingSlash
34+
return sourceServiceBaseURL
35+
.appendingPathComponent(path)
36+
.withFragment(lineNumber.map(formatLineNumber))
37+
}
38+
}
39+
40+
public extension SourceRepository {
41+
static func github(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
42+
SourceRepository(
43+
checkoutPath: checkoutPath,
44+
sourceServiceBaseURL: sourceServiceBaseURL,
45+
formatLineNumber: { line in "L\(line)" }
46+
)
47+
}
48+
49+
static func gitlab(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
50+
SourceRepository(
51+
checkoutPath: checkoutPath,
52+
sourceServiceBaseURL: sourceServiceBaseURL,
53+
formatLineNumber: { line in "L\(line)" }
54+
)
55+
}
56+
57+
static func bitbucket(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
58+
SourceRepository(
59+
checkoutPath: checkoutPath,
60+
sourceServiceBaseURL: sourceServiceBaseURL,
61+
formatLineNumber: { line in "lines-\(line)" }
62+
)
63+
}
64+
}

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

Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ extension ConvertAction {
8080
inheritDocs: convert.enableInheritedDocs,
8181
experimentalEnableCustomTemplates: convert.experimentalEnableCustomTemplates,
8282
transformForStaticHosting: convert.transformForStaticHosting,
83-
hostingBasePath: convert.hostingBasePath
83+
hostingBasePath: convert.hostingBasePath,
84+
sourceRepository: SourceRepository(from: convert.sourceRepositoryArguments)
8485
)
8586
}
8687
}

0 commit comments

Comments
 (0)