Skip to content

Update DocumentationContext initializer to be async #1244

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 52 additions & 95 deletions Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
Copyright (c) 2021-2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -44,68 +44,68 @@ public struct ConvertService: DocumentationService {
_ message: DocumentationServer.Message,
completion: @escaping (DocumentationServer.Message) -> ()
) {
let conversionResult = retrievePayload(message)
.flatMap(decodeRequest)
.flatMap(convert)
.flatMap(encodeResponse)

switch conversionResult {
case .success(let response):
completion(
DocumentationServer.Message(
type: Self.convertResponseMessageType,
identifier: "\(message.identifier)-response",
payload: response
Task {
let result = await process(message)
completion(result)
}
}

public func process(_ message: DocumentationServer.Message) async -> DocumentationServer.Message {
func makeErrorResponse(_ error: ConvertServiceError) -> DocumentationServer.Message {
DocumentationServer.Message(
type: Self.convertResponseErrorMessageType,
identifier: "\(message.identifier)-response-error",

// Force trying because encoding known messages should never fail.
payload: try! JSONEncoder().encode(error)
)
}

guard let payload = message.payload else {
return makeErrorResponse(.missingPayload())
}

let request: ConvertRequest
do {
request = try JSONDecoder().decode(ConvertRequest.self, from: payload)
} catch {
return makeErrorResponse(.invalidRequest(underlyingError: error.localizedDescription))
}

let renderNodes: [RenderNode]
let renderReferenceStore: RenderReferenceStore?
do {
(renderNodes, renderReferenceStore) = try await convert(request: request, messageIdentifier: message.identifier)
} catch {
return makeErrorResponse(.conversionError(underlyingError: error.localizedDescription))
}

do {
let encoder = JSONEncoder()
let encodedResponse = try encoder.encode(
try ConvertResponse(
renderNodes: renderNodes.map(encoder.encode),
renderReferenceStore: renderReferenceStore.map(encoder.encode)
)
)

case .failure(let error):
completion(
DocumentationServer.Message(
type: Self.convertResponseErrorMessageType,
identifier: "\(message.identifier)-response-error",

// Force trying because encoding known messages should never fail.
payload: try! JSONEncoder().encode(error)
)
return DocumentationServer.Message(
type: Self.convertResponseMessageType,
identifier: "\(message.identifier)-response",
payload: encodedResponse
)
}
}

/// Attempts to retrieve the payload from the given message, returning a failure if the payload is missing.
///
/// - Returns: A result with the message's payload if present, otherwise a ``ConvertServiceError/missingPayload``
/// failure.
private func retrievePayload(
_ message: DocumentationServer.Message
) -> Result<(payload: Data, messageIdentifier: String), ConvertServiceError> {
message.payload.map { .success(($0, message.identifier)) } ?? .failure(.missingPayload())
}

/// Attempts to decode the given request, returning a failure if decoding failed.
///
/// - Returns: A result with the decoded request if the decoding succeeded, otherwise a
/// ``ConvertServiceError/invalidRequest`` failure.
private func decodeRequest(
data: Data,
messageIdentifier: String
) -> Result<(request: ConvertRequest, messageIdentifier: String), ConvertServiceError> {
Result {
return (try JSONDecoder().decode(ConvertRequest.self, from: data), messageIdentifier)
}.mapErrorToConvertServiceError {
.invalidRequest(underlyingError: $0.localizedDescription)
} catch {
return makeErrorResponse(.invalidResponseMessage(underlyingError: error.localizedDescription))
}
}

/// Attempts to process the given convert request, returning a failure if the conversion failed.
///
/// - Returns: A result with the produced render nodes if the conversion was successful, otherwise a
/// ``ConvertServiceError/conversionError`` failure.
/// - Returns: A result with the produced render nodes if the conversion was successful
private func convert(
request: ConvertRequest,
messageIdentifier: String
) -> Result<([RenderNode], RenderReferenceStore?), ConvertServiceError> {
Result {
) async throws -> ([RenderNode], RenderReferenceStore?) {
// Update DocC's current feature flags based on the ones provided
// in the request.
FeatureFlags.current = request.featureFlags
Expand Down Expand Up @@ -155,7 +155,7 @@ public struct ConvertService: DocumentationService {
(bundle, dataProvider) = Self.makeBundleAndInMemoryDataProvider(request)
}

let context = try DocumentationContext(bundle: bundle, dataProvider: dataProvider, configuration: configuration)
let context = try await DocumentationContext(bundle: bundle, dataProvider: dataProvider, configuration: configuration)

// Precompute the render context
let renderContext = RenderContext(documentationContext: context, bundle: bundle)
Expand Down Expand Up @@ -228,30 +228,6 @@ public struct ConvertService: DocumentationService {
}

return (renderNodes, referenceStore)
}.mapErrorToConvertServiceError {
.conversionError(underlyingError: $0.localizedDescription)
}
}

/// Encodes a conversion response to send to the client.
///
/// - Parameter renderNodes: The render nodes that were produced as part of the conversion.
private func encodeResponse(
renderNodes: [RenderNode],
renderReferenceStore: RenderReferenceStore?
) -> Result<Data, ConvertServiceError> {
Result {
let encoder = JSONEncoder()

return try encoder.encode(
try ConvertResponse(
renderNodes: renderNodes.map(encoder.encode),
renderReferenceStore: renderReferenceStore.map(encoder.encode)
)
)
}.mapErrorToConvertServiceError {
.invalidResponseMessage(underlyingError: $0.localizedDescription)
}
}

/// Takes a base reference store and adds uncurated article references and documentation extensions.
Expand Down Expand Up @@ -297,25 +273,6 @@ public struct ConvertService: DocumentationService {
}
}

extension Result {
/// Returns a new result, mapping any failure value using the given transformation if the error is not a conversion error.
///
/// If the error value is a ``ConvertServiceError``, it is returned as-is. If it's not, the given transformation is called on the
/// error.
///
/// - Parameter transform: A closure that takes the failure value of the instance.
func mapErrorToConvertServiceError(
_ transform: (any Error) -> ConvertServiceError
) -> Result<Success, ConvertServiceError> {
mapError { error in
switch error {
case let error as ConvertServiceError: return error
default: return transform(error)
}
}
}
}

private extension SymbolGraph.LineList.Line {
/// Creates a line given a convert request line.
init(_ line: ConvertRequest.Line) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ public class DocumentationContext {
dataProvider: any DataProvider,
diagnosticEngine: DiagnosticEngine = .init(),
configuration: Configuration = .init()
) throws {
) async throws {
self.bundle = bundle
self.dataProvider = .new(dataProvider)
self.diagnosticEngine = diagnosticEngine
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,9 @@ public struct ConvertAction: AsyncAction {

let indexer = try Indexer(outputURL: temporaryFolder, bundleID: bundle.id)

let context = try signposter.withIntervalSignpost("Register", id: signposter.makeSignpostID()) {
try DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration)
}
let registerInterval = signposter.beginInterval("Register", id: signposter.makeSignpostID())
let context = try await DocumentationContext(bundle: bundle, dataProvider: dataProvider, diagnosticEngine: diagnosticEngine, configuration: configuration)
signposter.endInterval("Register", registerInterval)

let outputConsumer = ConvertFileWritingConsumer(
targetFolder: temporaryFolder,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2024 Apple Inc. and the Swift project authors
Copyright (c) 2024-2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -50,7 +50,7 @@ struct EmitGeneratedCurationAction: AsyncAction {
additionalSymbolGraphFiles: symbolGraphFiles(in: additionalSymbolGraphDirectory)
)
)
let context = try DocumentationContext(bundle: bundle, dataProvider: dataProvider)
let context = try await DocumentationContext(bundle: bundle, dataProvider: dataProvider)

let writer = GeneratedCurationWriter(context: context, catalogURL: catalogURL, outputURL: outputURL)
let curation = try writer.generateDefaultCurationContents(fromSymbol: startingPointSymbolLink, depthLimit: depthLimit)
Expand Down
54 changes: 25 additions & 29 deletions Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
Copyright (c) 2021-2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -37,9 +37,9 @@ class ExternalTopicsGraphHashTests: XCTestCase {
}
}

func testNoMetricAddedIfNoExternalTopicsAreResolved() throws {
func testNoMetricAddedIfNoExternalTopicsAreResolved() async throws {
// Load bundle without using external resolvers
let (_, context) = try testBundleAndContext(named: "LegacyBundle_DoNotUseInNewTests")
let (_, context) = try await testBundleAndContext(named: "LegacyBundle_DoNotUseInNewTests")
XCTAssertTrue(context.externallyResolvedLinks.isEmpty)

// Try adding external topics metrics
Expand All @@ -50,12 +50,12 @@ class ExternalTopicsGraphHashTests: XCTestCase {
XCTAssertNil(testBenchmark.metrics.first?.result, "Metric was added but there was no external links or symbols")
}

func testExternalLinksSameHash() throws {
func testExternalLinksSameHash() async throws {
let externalResolver = self.externalResolver

// Add external links and verify the checksum is always the same
let hashes: [String] = try (0...10).map { _ -> MetricValue? in
let (_, _, context) = try testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", externalResolvers: [externalResolver.bundleID: externalResolver]) { url in
func computeTopicHash(file: StaticString = #filePath, line: UInt = #line) async throws -> String {
let (_, _, context) = try await self.testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", externalResolvers: [externalResolver.bundleID: externalResolver]) { url in
try """
# ``SideKit/SideClass``

Expand All @@ -74,26 +74,24 @@ class ExternalTopicsGraphHashTests: XCTestCase {
let testBenchmark = Benchmark()
benchmark(add: Benchmark.ExternalTopicsHash(context: context), benchmarkLog: testBenchmark)

// Verify that a metric was added
XCTAssertNotNil(testBenchmark.metrics[0].result)
return testBenchmark.metrics[0].result
}
.compactMap { value -> String? in
guard let value,
case MetricValue.checksum(let hash) = value else { return nil }
return hash
return try TopicAnchorHashTests.extractChecksumHash(from: testBenchmark)
}

let expectedHash = try await computeTopicHash()

// Verify the produced topic graph hash is repeatedly the same
XCTAssertTrue(hashes.allSatisfy({ $0 == hashes.first }))
for _ in 0 ..< 10 {
let hash = try await computeTopicHash()
XCTAssertEqual(hash, expectedHash)
}
}

func testLinksAndSymbolsSameHash() throws {
func testLinksAndSymbolsSameHash() async throws {
let externalResolver = self.externalResolver

// Add external links and verify the checksum is always the same
let hashes: [String] = try (0...10).map { _ -> MetricValue? in
let (_, _, context) = try testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", externalResolvers: [externalResolver.bundleID: externalResolver], externalSymbolResolver: externalSymbolResolver) { url in
func computeTopicHash(file: StaticString = #filePath, line: UInt = #line) async throws -> String {
let (_, _, context) = try await self.testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", externalResolvers: [externalResolver.bundleID: externalResolver], externalSymbolResolver: self.externalSymbolResolver) { url in
try """
# ``SideKit/SideClass``

Expand All @@ -113,25 +111,23 @@ class ExternalTopicsGraphHashTests: XCTestCase {
let testBenchmark = Benchmark()
benchmark(add: Benchmark.ExternalTopicsHash(context: context), benchmarkLog: testBenchmark)

// Verify that a metric was added
XCTAssertNotNil(testBenchmark.metrics[0].result)
return testBenchmark.metrics[0].result
}
.compactMap { value -> String? in
guard let value,
case MetricValue.checksum(let hash) = value else { return nil }
return hash
return try TopicAnchorHashTests.extractChecksumHash(from: testBenchmark)
}

let expectedHash = try await computeTopicHash()

// Verify the produced topic graph hash is repeatedly the same
XCTAssertTrue(hashes.allSatisfy({ $0 == hashes.first }))
for _ in 0 ..< 10 {
let hash = try await computeTopicHash()
XCTAssertEqual(hash, expectedHash)
}
}

func testExternalTopicsDetectsChanges() throws {
func testExternalTopicsDetectsChanges() async throws {
let externalResolver = self.externalResolver

// Load a bundle with external links
let (_, _, context) = try testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", externalResolvers: [externalResolver.bundleID: externalResolver]) { url in
let (_, _, context) = try await testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", externalResolvers: [externalResolver.bundleID: externalResolver]) { url in
try """
# ``SideKit/SideClass``

Expand Down
Loading