Skip to content

Commit 9d4106b

Browse files
Make ResolvedTopicReference immutable and copy-on-write
Co-authored-by: Franklin Schrans <[email protected]>
1 parent 2be8805 commit 9d4106b

File tree

7 files changed

+129
-71
lines changed

7 files changed

+129
-71
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
454454
externallyResolvedLinks[pair.url] = pair.resolved
455455
if case .success(let resolvedReference) = pair.resolved,
456456
pair.url.absoluteString != resolvedReference.absoluteString,
457-
let url = resolvedReference.url.flatMap(ValidatedURL.init) {
457+
let url = ValidatedURL(resolvedReference.url) {
458458
// If the resolved reference has a different URL than the link cache both URLs
459459
// so we can resolve both unresolved and resolved references.
460460
externallyResolvedLinks[url] = pair.resolved
@@ -1110,7 +1110,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
11101110
}
11111111

11121112
// If it's an existing module, update the interface languages
1113-
moduleReferences[moduleName]?.sourceLanguages.formUnion(moduleInterfaceLanguages)
1113+
moduleReferences[moduleName] = moduleReferences[moduleName]?.addingSourceLanguages(moduleInterfaceLanguages)
11141114

11151115
// Import the symbol graph symbols
11161116
let moduleReference: ResolvedTopicReference

Sources/SwiftDocC/Model/Identifier.swift

Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,15 @@ public enum TopicReferenceResolutionResult: Hashable, CustomStringConvertible {
6666
}
6767
}
6868

69-
/**
70-
A reference to a piece of documentation which has been verified to exist.
71-
72-
A `ResolvedTopicReference` refers to some piece of documentation, such as an article or symbol. Once an `UnresolvedTopicReference` has been resolved to this type, it should be guaranteed that the content backing the documentation is available (i.e. there is a file on disk or data in memory ready to be recalled at any time).
73-
*/
69+
/// A reference to a piece of documentation which has been verified to exist.
70+
///
71+
/// A `ResolvedTopicReference` refers to some piece of documentation, such as an article or symbol.
72+
/// Once an `UnresolvedTopicReference` has been resolved to this type, it should be guaranteed
73+
/// that the content backing the documentation is available
74+
/// (i.e. there is a file on disk or data in memory ready to be
75+
/// recalled at any time).
76+
///
77+
/// > Important: This type has copy-on-write semantics.
7478
public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomStringConvertible {
7579
typealias ReferenceBundleIdentifier = String
7680
typealias ReferenceKey = String
@@ -92,30 +96,34 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
9296
return url?.scheme?.lowercased() == ResolvedTopicReference.urlScheme
9397
}
9498

99+
/// The storage for the resolved topic reference's state.
100+
let _storage: Storage
101+
95102
/// The identifier of the bundle that owns this documentation topic.
96103
public var bundleIdentifier: String {
97-
didSet { updateURL() }
104+
return _storage.bundleIdentifier
98105
}
99106

100107
/// The absolute path from the bundle to this topic, delimited by `/`.
101108
public var path: String {
102-
didSet { updateURL() }
109+
return _storage.path
103110
}
104111

105112
/// A URL fragment referring to a resource in the topic.
106113
public var fragment: String? {
107-
didSet { updateURL() }
114+
return _storage.fragment
108115
}
109116

110117
/// The source language for which this topic is relevant.
111118
public var sourceLanguage: SourceLanguage {
112119
// Return Swift by default to maintain backwards-compatibility.
113-
get { sourceLanguages.contains(.swift) ? .swift : sourceLanguages.first! }
114-
set { sourceLanguages.insert(newValue) }
120+
return sourceLanguages.contains(.swift) ? .swift : sourceLanguages.first!
115121
}
116122

117123
/// The source languages for which this topic is relevant.
118-
public var sourceLanguages: Set<SourceLanguage>
124+
public var sourceLanguages: Set<SourceLanguage> {
125+
return _storage.sourceLanguages
126+
}
119127

120128
/// - Note: The `path` parameter is escaped to a path readable string.
121129
public init(bundleIdentifier: String, path: String, fragment: String? = nil, sourceLanguage: SourceLanguage) {
@@ -132,20 +140,20 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
132140
return
133141
}
134142

135-
// Create a new reference
136-
self.bundleIdentifier = bundleIdentifier
137-
self.path = urlReadablePath(path)
138-
self.fragment = fragment.map { urlReadableFragment($0) }
139-
self.sourceLanguages = sourceLanguages
140-
updateURL()
143+
_storage = Storage(
144+
bundleIdentifier: bundleIdentifier,
145+
path: urlReadablePath,
146+
fragment: urlReadableFragment,
147+
sourceLanguages: sourceLanguages
148+
)
141149

142150
// Cache the reference
143151
Self.sharedPool.sync { $0[bundleIdentifier, default: [:]][key] = self }
144152
}
145153

146154
private static func cacheKey(
147-
path: String,
148-
fragment: String?,
155+
urlReadablePath path: String,
156+
urlReadableFragment fragment: String?,
149157
sourceLanguages: Set<SourceLanguage>
150158
) -> String {
151159
let sourceLanguagesString = sourceLanguages.map(\.id).sorted().joined(separator: "-")
@@ -158,28 +166,19 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
158166
}
159167

160168
/// The topic URL as you would write in a link.
161-
private (set) public var url: URL! = nil
162-
163-
private mutating func updateURL() {
164-
var components = URLComponents()
165-
components.scheme = ResolvedTopicReference.urlScheme
166-
components.host = bundleIdentifier
167-
components.path = path
168-
components.fragment = fragment
169-
url = components.url!
170-
pathComponents = url.pathComponents
171-
absoluteString = url.absoluteString
169+
public var url: URL {
170+
return _storage.url
172171
}
173172

174173
/// A list of the reference path components.
175-
/// > Note: This value is updated inside `updateURL()` to avoid
176-
/// accessing the property on `URL`.
177-
private(set) var pathComponents = [String]()
174+
var pathComponents: [String] {
175+
return _storage.pathComponents
176+
}
178177

179178
/// A string representation of `url`.
180-
/// > Note: This value is updated inside `updateURL()` to avoid
181-
/// accessing the property on `URL`.
182-
private(set) var absoluteString = ""
179+
var absoluteString: String {
180+
return _storage.absoluteString
181+
}
183182

184183
enum CodingKeys: CodingKey {
185184
case url, interfaceLanguage
@@ -284,6 +283,25 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
284283
return newReference
285284
}
286285

286+
/// Returns a topic reference based on the current one that includes the given source languages.
287+
///
288+
/// If the current topic reference already includes the given source languages, this returns
289+
/// the original topic reference.
290+
public func addingSourceLanguages(_ sourceLanguages: Set<SourceLanguage>) -> ResolvedTopicReference {
291+
let combinedSourceLanguages = self.sourceLanguages.union(sourceLanguages)
292+
293+
guard combinedSourceLanguages != self.sourceLanguages else {
294+
return self
295+
}
296+
297+
return ResolvedTopicReference(
298+
bundleIdentifier: bundleIdentifier,
299+
urlReadablePath: path,
300+
urlReadableFragment: fragment,
301+
sourceLanguages: combinedSourceLanguages
302+
)
303+
}
304+
287305
/// The last path component of this topic reference.
288306
public var lastPathComponent: String {
289307
// There is always at least one component, so we can unwrap `last`.
@@ -322,15 +340,48 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
322340
// which languages they are available in.
323341

324342
public func hash(into hasher: inout Hasher) {
325-
hasher.combine(bundleIdentifier)
326-
hasher.combine(path)
327-
hasher.combine(fragment)
343+
hasher.combine(_storage.identifierPathAndFragment)
328344
}
329345

330346
public static func == (lhs: ResolvedTopicReference, rhs: ResolvedTopicReference) -> Bool {
331-
return lhs.bundleIdentifier == rhs.bundleIdentifier
332-
&& lhs.path == rhs.path
333-
&& lhs.fragment == rhs.fragment
347+
return lhs._storage.identifierPathAndFragment == rhs._storage.identifierPathAndFragment
348+
}
349+
350+
/// Storage for a resolved topic reference's state.
351+
///
352+
/// This is a reference type which allows ``ResolvedTopicReference`` to have copy-on-write behavior.
353+
class Storage {
354+
let bundleIdentifier: String
355+
let path: String
356+
let fragment: String?
357+
let sourceLanguages: Set<SourceLanguage>
358+
let url: URL
359+
let pathComponents: [String]
360+
let absoluteString: String
361+
362+
let identifierPathAndFragment: String
363+
364+
init(
365+
bundleIdentifier: String,
366+
path: String,
367+
fragment: String? = nil,
368+
sourceLanguages: Set<SourceLanguage>
369+
) {
370+
self.bundleIdentifier = bundleIdentifier
371+
self.path = path
372+
self.fragment = fragment
373+
self.sourceLanguages = sourceLanguages
374+
self.identifierPathAndFragment = "\(bundleIdentifier)\(path)\(fragment ?? "")"
375+
376+
var components = URLComponents()
377+
components.scheme = ResolvedTopicReference.urlScheme
378+
components.host = bundleIdentifier
379+
components.path = path
380+
components.fragment = fragment
381+
url = components.url!
382+
pathComponents = url.pathComponents
383+
absoluteString = url.absoluteString
384+
}
334385
}
335386
}
336387

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
804804
title: group.title,
805805
abstract: nil,
806806
discussion: nil,
807-
identifiers: group.references.compactMap(\.url?.absoluteString),
807+
identifiers: group.references.map(\.url.absoluteString),
808808
generated: true
809809
)
810810
}
@@ -892,10 +892,8 @@ public struct RenderNodeTranslator: SemanticVisitor {
892892
public mutating func visitSymbol(_ symbol: Symbol) -> RenderTree? {
893893
let documentationNode = try! context.entity(with: identifier)
894894

895-
var identifier = identifier
895+
let identifier = identifier.addingSourceLanguages(documentationNode.availableSourceLanguages)
896896

897-
// Add the source languages declared in the documentation node.
898-
identifier.sourceLanguages = identifier.sourceLanguages.union(documentationNode.availableSourceLanguages)
899897
var node = RenderNode(identifier: identifier, kind: .symbol)
900898
var contentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: identifier)
901899

Tests/SwiftDocCTests/Infrastructure/ResolvedTopicReferenceTests.swift

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,27 @@ import XCTest
1515

1616
class ResolvedTopicReferenceTests: XCTestCase {
1717
func testReferenceURL() {
18-
var reference = ResolvedTopicReference(bundleIdentifier: "bundleID", path: "/path/sub-path", fragment: "fragment", sourceLanguage: .swift)
19-
XCTAssertEqual(reference.absoluteString, "doc://bundleID/path/sub-path#fragment")
18+
let firstTopicReference = ResolvedTopicReference(
19+
bundleIdentifier: "bundleID",
20+
path: "/path/sub-path",
21+
fragment: "fragment",
22+
sourceLanguage: .swift
23+
)
24+
XCTAssertEqual(firstTopicReference.absoluteString, "doc://bundleID/path/sub-path#fragment")
2025

21-
reference.bundleIdentifier = "new-bundleID"
22-
XCTAssertEqual(reference.absoluteString, "doc://new-bundleID/path/sub-path#fragment")
26+
let secondTopicReference = ResolvedTopicReference(
27+
bundleIdentifier: "new-bundleID",
28+
path: "/new-path/sub-path",
29+
fragment: firstTopicReference.fragment,
30+
sourceLanguage: firstTopicReference.sourceLanguage
31+
)
32+
XCTAssertEqual(secondTopicReference.absoluteString, "doc://new-bundleID/new-path/sub-path#fragment")
2333

24-
reference.path = "/new-path/sub-path"
25-
XCTAssertEqual(reference.absoluteString, "doc://new-bundleID/new-path/sub-path#fragment")
26-
27-
reference.fragment = nil
28-
XCTAssertEqual(reference.absoluteString, "doc://new-bundleID/new-path/sub-path")
34+
let thirdTopicReference = secondTopicReference.withFragment(nil)
35+
XCTAssertEqual(thirdTopicReference.absoluteString, "doc://new-bundleID/new-path/sub-path")
2936

3037
// Changing the language does not change the url
31-
reference.sourceLanguage = .metal
32-
XCTAssertEqual(reference.absoluteString, "doc://new-bundleID/new-path/sub-path")
38+
XCTAssertEqual(thirdTopicReference.addingSourceLanguages([.metal]).absoluteString, "doc://new-bundleID/new-path/sub-path")
3339
}
3440

3541
func testAppendingReferenceWithEmptyPath() {

Tests/SwiftDocCTests/Infrastructure/Symbol Link Resolution/AbsoluteSymbolLinkTests.swift

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -524,9 +524,7 @@ class AbsoluteSymbolLinkTests: XCTestCase {
524524
XCTAssertEqual(expectedDescriptions.count, context.symbolIndex.count)
525525

526526
let validatedSymbolLinkDescriptions = context.symbolIndex.values
527-
.map(\.reference)
528-
.compactMap(\.url)
529-
.map(\.absoluteString)
527+
.map(\.reference.url.absoluteString)
530528
.sorted()
531529
.compactMap(AbsoluteSymbolLink.init(string:))
532530
.map(\.description)
@@ -850,9 +848,7 @@ class AbsoluteSymbolLinkTests: XCTestCase {
850848
XCTAssertEqual(expectedDescriptions.count, context.symbolIndex.count)
851849

852850
let validatedSymbolLinkDescriptions = context.symbolIndex.values
853-
.map(\.reference)
854-
.compactMap(\.url)
855-
.map(\.absoluteString)
851+
.map(\.reference.url.absoluteString)
856852
.sorted()
857853
.compactMap(AbsoluteSymbolLink.init(string:))
858854
.map(\.description)
@@ -872,10 +868,7 @@ class AbsoluteSymbolLinkTests: XCTestCase {
872868
defer { try? FileManager.default.removeItem(at: url) }
873869

874870
let bundlePathComponents = context.symbolIndex.values
875-
.map(\.reference)
876-
.compactMap(\.url)
877-
.map(\.pathComponents)
878-
.flatMap { $0 }
871+
.flatMap(\.reference.pathComponents)
879872

880873

881874
bundlePathComponents.forEach { component in

Tests/SwiftDocCTests/Model/IdentifierTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,14 @@ class IdentifierTests: XCTestCase {
121121
ref1 = ref1.removingLastPathComponent()
122122
XCTAssertEqual(ref1.absoluteString, "doc://bundle/MyClass")
123123
}
124+
125+
func testResolvedTopicReferenceDoesNotCopyStorageIfNotModified() {
126+
let reference1 = ResolvedTopicReference(bundleIdentifier: "bundle", path: "/", sourceLanguage: .swift)
127+
let reference2 = reference1
128+
129+
XCTAssertEqual(
130+
ObjectIdentifier(reference1._storage),
131+
ObjectIdentifier(reference2._storage)
132+
)
133+
}
124134
}

Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,10 +1240,10 @@ class SemaToRenderNodeTests: XCTestCase {
12401240
reference: reference,
12411241
kind: .collection,
12421242
sourceLanguage: .swift,
1243-
name: .conceptual(title: "Title for \(reference.url!.path)"),
1243+
name: .conceptual(title: "Title for \(reference.url.path)"),
12441244
markup: Document(
12451245
Paragraph(
1246-
Text("Abstract for \(reference.url!.path)")
1246+
Text("Abstract for \(reference.url.path)")
12471247
)
12481248
),
12491249
semantic: nil

0 commit comments

Comments
 (0)