Skip to content

Commit e76ef47

Browse files
Make ResolvedTopicReference immutable and copy-on-write
Co-authored-by: Franklin Schrans <[email protected]>
1 parent 56d247f commit e76ef47

File tree

7 files changed

+138
-71
lines changed

7 files changed

+138
-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: 102 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,24 @@ 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+
/// ## Implementation Details
78+
///
79+
/// `ResolvedTopicReference` is effectively a wrapper around Foundation's `URL` and,
80+
/// because of this, it exposes an API very similar to `URL` and does not allow direct modification
81+
/// of its properties. This immutability brings performance benefits and communicates with
82+
/// user's of the API that doing something like adding a path component
83+
/// is a potentially expensive operation, just as it is on `URL`.
84+
///
85+
/// > Important: This type has copy-on-write semantics and wraps an underlying class to store
86+
/// > its data.
7487
public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomStringConvertible {
7588
typealias ReferenceBundleIdentifier = String
7689
typealias ReferenceKey = String
@@ -92,30 +105,34 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
92105
return url?.scheme?.lowercased() == ResolvedTopicReference.urlScheme
93106
}
94107

108+
/// The storage for the resolved topic reference's state.
109+
let _storage: Storage
110+
95111
/// The identifier of the bundle that owns this documentation topic.
96112
public var bundleIdentifier: String {
97-
didSet { updateURL() }
113+
return _storage.bundleIdentifier
98114
}
99115

100116
/// The absolute path from the bundle to this topic, delimited by `/`.
101117
public var path: String {
102-
didSet { updateURL() }
118+
return _storage.path
103119
}
104120

105121
/// A URL fragment referring to a resource in the topic.
106122
public var fragment: String? {
107-
didSet { updateURL() }
123+
return _storage.fragment
108124
}
109125

110126
/// The source language for which this topic is relevant.
111127
public var sourceLanguage: SourceLanguage {
112128
// Return Swift by default to maintain backwards-compatibility.
113-
get { sourceLanguages.contains(.swift) ? .swift : sourceLanguages.first! }
114-
set { sourceLanguages.insert(newValue) }
129+
return sourceLanguages.contains(.swift) ? .swift : sourceLanguages.first!
115130
}
116131

117132
/// The source languages for which this topic is relevant.
118-
public var sourceLanguages: Set<SourceLanguage>
133+
public var sourceLanguages: Set<SourceLanguage> {
134+
return _storage.sourceLanguages
135+
}
119136

120137
/// - Note: The `path` parameter is escaped to a path readable string.
121138
public init(bundleIdentifier: String, path: String, fragment: String? = nil, sourceLanguage: SourceLanguage) {
@@ -132,20 +149,20 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
132149
return
133150
}
134151

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()
152+
_storage = Storage(
153+
bundleIdentifier: bundleIdentifier,
154+
path: urlReadablePath,
155+
fragment: urlReadableFragment,
156+
sourceLanguages: sourceLanguages
157+
)
141158

142159
// Cache the reference
143160
Self.sharedPool.sync { $0[bundleIdentifier, default: [:]][key] = self }
144161
}
145162

146163
private static func cacheKey(
147-
path: String,
148-
fragment: String?,
164+
urlReadablePath path: String,
165+
urlReadableFragment fragment: String?,
149166
sourceLanguages: Set<SourceLanguage>
150167
) -> String {
151168
let sourceLanguagesString = sourceLanguages.map(\.id).sorted().joined(separator: "-")
@@ -158,28 +175,19 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
158175
}
159176

160177
/// 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
178+
public var url: URL {
179+
return _storage.url
172180
}
173181

174182
/// 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]()
183+
var pathComponents: [String] {
184+
return _storage.pathComponents
185+
}
178186

179187
/// 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 = ""
188+
var absoluteString: String {
189+
return _storage.absoluteString
190+
}
183191

184192
enum CodingKeys: CodingKey {
185193
case url, interfaceLanguage
@@ -284,6 +292,25 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
284292
return newReference
285293
}
286294

295+
/// Returns a topic reference based on the current one that includes the given source languages.
296+
///
297+
/// If the current topic reference already includes the given source languages, this returns
298+
/// the original topic reference.
299+
public func addingSourceLanguages(_ sourceLanguages: Set<SourceLanguage>) -> ResolvedTopicReference {
300+
let combinedSourceLanguages = self.sourceLanguages.union(sourceLanguages)
301+
302+
guard combinedSourceLanguages != self.sourceLanguages else {
303+
return self
304+
}
305+
306+
return ResolvedTopicReference(
307+
bundleIdentifier: bundleIdentifier,
308+
urlReadablePath: path,
309+
urlReadableFragment: fragment,
310+
sourceLanguages: combinedSourceLanguages
311+
)
312+
}
313+
287314
/// The last path component of this topic reference.
288315
public var lastPathComponent: String {
289316
// There is always at least one component, so we can unwrap `last`.
@@ -322,15 +349,48 @@ public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomString
322349
// which languages they are available in.
323350

324351
public func hash(into hasher: inout Hasher) {
325-
hasher.combine(bundleIdentifier)
326-
hasher.combine(path)
327-
hasher.combine(fragment)
352+
hasher.combine(_storage.identifierPathAndFragment)
328353
}
329354

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

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)