Skip to content

Commit de843c4

Browse files
authored
Support resolving code file references in ConvertService (#570)
* Support resolving code file references in ConvertService rdar://107965493 * Support highlighting lines in resolved file assets
1 parent 3e604f5 commit de843c4

File tree

5 files changed

+227
-14
lines changed

5 files changed

+227
-14
lines changed

Sources/SwiftDocC/Model/Rendering/References/FileReference.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,21 @@ public struct FileReference: RenderReference, Equatable {
4747
/// - fileType: The type of file, typically represented by its file extension.
4848
/// - syntax: The syntax of the file's content.
4949
/// - content: The line-by-line contents of the file.
50-
public init(identifier: RenderReferenceIdentifier, fileName: String, fileType: String, syntax: String, content: [String]) {
50+
/// - highlights: The line highlights for this file.
51+
public init(
52+
identifier: RenderReferenceIdentifier,
53+
fileName: String,
54+
fileType: String,
55+
syntax: String,
56+
content: [String],
57+
highlights: [LineHighlighter.Highlight] = []
58+
) {
5159
self.identifier = identifier
5260
self.fileName = fileName
5361
self.fileType = fileType
5462
self.syntax = syntax
5563
self.content = content
64+
self.highlights = highlights
5665
}
5766

5867
public init(from decoder: Decoder) throws {

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ public struct RenderNodeTranslator: SemanticVisitor {
5555

5656
public mutating func visitCode(_ code: Code) -> RenderTree? {
5757
let fileType = NSString(string: code.fileName).pathExtension
58-
let fileReference = code.fileReference
58+
guard let fileIdentifier = context.identifier(forAssetName: code.fileReference.path, in: identifier) else {
59+
return nil
60+
}
5961

60-
guard let fileData = try? context.resource(with: code.fileReference),
61-
let fileContents = String(data: fileData, encoding: .utf8) else {
62-
return RenderReferenceIdentifier("")
62+
let fileReference = ResourceReference(bundleIdentifier: code.fileReference.bundleIdentifier, path: fileIdentifier)
63+
guard let fileContents = fileContents(with: fileReference) else {
64+
return nil
6365
}
6466

6567
let assetReference = RenderReferenceIdentifier(fileReference.path)
@@ -74,6 +76,21 @@ public struct RenderNodeTranslator: SemanticVisitor {
7476
return assetReference
7577
}
7678

79+
private func fileContents(with fileReference: ResourceReference) -> String? {
80+
// Check if the file is a local asset that can be read directly from the context
81+
if let fileData = try? context.resource(with: fileReference) {
82+
return String(data: fileData, encoding: .utf8)
83+
}
84+
// Check if the file needs to be resolved to read its content
85+
else if let asset = context.resolveAsset(named: fileReference.path, in: identifier) {
86+
return try? String(contentsOf: asset.data(bestMatching: DataTraitCollection()).url, encoding: .utf8)
87+
}
88+
// Couldn't find the file reference's content
89+
else {
90+
return nil
91+
}
92+
}
93+
7794
public mutating func visitSteps(_ steps: Steps) -> RenderTree? {
7895
let stepsContent = steps.content.flatMap { child -> [RenderBlockContent] in
7996
return visit(child) as! [RenderBlockContent]
@@ -107,7 +124,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
107124
stepsContent = []
108125
}
109126

110-
let highlightsPerFile = LineHighlighter(context: context, tutorialSection: tutorialSection).highlights
127+
let highlightsPerFile = LineHighlighter(context: context, tutorialSection: tutorialSection, tutorialReference: identifier).highlights
111128

112129
// Add the highlights to the file references.
113130
for result in highlightsPerFile {

Sources/SwiftDocC/Model/Rendering/Tutorial/LineHighlighter.swift

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,23 +90,38 @@ public struct LineHighlighter {
9090
/// The ``TutorialSection`` whose ``Steps`` will be analyzed for their code highlights.
9191
private let tutorialSection: TutorialSection
9292

93-
init(context: DocumentationContext, tutorialSection: TutorialSection) {
93+
/// The topic reference of the tutorial whose section will be analyzed for their code highlights.
94+
private let tutorialReference: ResolvedTopicReference
95+
96+
init(context: DocumentationContext, tutorialSection: TutorialSection, tutorialReference: ResolvedTopicReference) {
9497
self.context = context
9598
self.tutorialSection = tutorialSection
99+
self.tutorialReference = tutorialReference
96100
}
97101

98102
/// The lines in the `resource` file.
99-
private func lines(of resource: ResourceReference) throws -> [String] {
100-
let data = try context.resource(with: ResourceReference(bundleIdentifier: resource.bundleIdentifier, path: resource.path))
101-
return String(data: data, encoding: .utf8)?.splitByNewlines ?? []
103+
private func lines(of resource: ResourceReference) -> [String]? {
104+
let fileContent: String?
105+
// Check if the file is a local asset that can be read directly from the context
106+
if let fileData = try? context.resource(with: resource) {
107+
fileContent = String(data: fileData, encoding: .utf8)
108+
}
109+
// Check if the file needs to be resolved to read its content
110+
else if let asset = context.resolveAsset(named: resource.path, in: tutorialReference) {
111+
fileContent = try? String(contentsOf: asset.data(bestMatching: DataTraitCollection()).url, encoding: .utf8)
112+
}
113+
// Couldn't find the file reference's content
114+
else {
115+
fileContent = nil
116+
}
117+
return fileContent?.splitByNewlines
102118
}
103119

104120
/// Returns the line highlights between two files.
105121
private func lineHighlights(old: ResourceReference, new: ResourceReference) -> Result {
106122
// Retrieve the contents of the current file and the file we're comparing against.
107-
guard let oldLines = try? lines(of: old),
108-
let newLines = try? lines(of: new) else {
109-
return Result(file: new, highlights: [])
123+
guard let oldLines = lines(of: old), let newLines = lines(of: new) else {
124+
return Result(file: new, highlights: [])
110125
}
111126

112127
let diff = newLines.difference(from: oldLines)

Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import XCTest
1212
import Foundation
1313
@testable import SwiftDocC
1414
import SymbolKit
15+
import SwiftDocCTestUtilities
1516

1617
class ConvertServiceTests: XCTestCase {
1718
private let testBundleInfo = DocumentationBundle.Info(
@@ -835,6 +836,177 @@ class ConvertServiceTests: XCTestCase {
835836
}
836837
}
837838

839+
func testConvertTutorialWithCode() throws {
840+
let tutorialContent = """
841+
@Tutorial(time: 99) {
842+
@Intro(title: "Tutorial Title") {
843+
Tutorial intro.
844+
}
845+
@Section(title: "Section title") {
846+
This section has one step with a code file reference.
847+
848+
@Steps {
849+
@Step {
850+
Start with this
851+
852+
@Code(name: "Something.swift", file: before.swift)
853+
}
854+
855+
@Step {
856+
Add this
857+
858+
@Code(name: "Something.swift", file: after.swift)
859+
}
860+
}
861+
}
862+
}
863+
"""
864+
865+
let tempURL = try createTempFolder(content: [
866+
Folder(name: "TutorialWithCodeTest.docc", content: [
867+
TextFile(name: "Something.tutorial", utf8Content: tutorialContent),
868+
869+
TextFile(name: "before.swift", utf8Content: """
870+
// This is an example swift file
871+
"""),
872+
TextFile(name: "after.swift", utf8Content: """
873+
// This is an example swift file
874+
let something = 0
875+
"""),
876+
])
877+
])
878+
let catalog = tempURL.appendingPathComponent("TutorialWithCodeTest.docc")
879+
880+
let request = ConvertRequest(
881+
bundleInfo: testBundleInfo,
882+
externalIDsToConvert: nil,
883+
documentPathsToConvert: nil,
884+
bundleLocation: nil,
885+
symbolGraphs: [],
886+
knownDisambiguatedSymbolPathComponents: nil,
887+
markupFiles: [],
888+
tutorialFiles: [tutorialContent.data(using: .utf8)!],
889+
miscResourceURLs: []
890+
)
891+
892+
let server = DocumentationServer()
893+
894+
let mockLinkResolvingService = LinkResolvingService { message in
895+
XCTAssertEqual(message.type, "resolve-reference")
896+
XCTAssert(message.identifier.hasPrefix("SwiftDocC"))
897+
do {
898+
let payload = try XCTUnwrap(message.payload)
899+
let request = try JSONDecoder()
900+
.decode(
901+
ConvertRequestContextWrapper<OutOfProcessReferenceResolver.Request>.self,
902+
from: payload
903+
)
904+
905+
XCTAssertEqual(request.convertRequestIdentifier, "test-identifier")
906+
907+
switch request.payload {
908+
case .topic(let url):
909+
XCTFail("Unexpected topic request: \(url.absoluteString.singleQuoted)")
910+
// Fail to resolve every topic
911+
return DocumentationServer.Message(
912+
type: "resolve-reference-response",
913+
payload: try JSONEncoder().encode(
914+
OutOfProcessReferenceResolver.Response.errorMessage("Unexpected topic request")
915+
)
916+
)
917+
918+
case .symbol(let preciseIdentifier):
919+
XCTFail("Unexpected symbol request: \(preciseIdentifier)")
920+
// Fail to resolve every symbol
921+
return DocumentationServer.Message(
922+
type: "resolve-reference-response",
923+
payload: try JSONEncoder().encode(
924+
OutOfProcessReferenceResolver.Response.errorMessage("Unexpected symbol request")
925+
)
926+
)
927+
928+
case .asset(let assetReference):
929+
print(assetReference)
930+
switch (assetReference.assetName, assetReference.bundleIdentifier) {
931+
case (let assetName, "identifier") where ["before.swift", "after.swift"].contains(assetName):
932+
var asset = DataAsset()
933+
asset.register(
934+
catalog.appendingPathComponent(assetName),
935+
with: DataTraitCollection()
936+
)
937+
938+
return DocumentationServer.Message(
939+
type: "resolve-reference-response",
940+
payload: try JSONEncoder().encode(
941+
OutOfProcessReferenceResolver.Response
942+
.asset(asset)
943+
)
944+
)
945+
946+
default:
947+
XCTFail("Unexpected asset request: \(assetReference.assetName)")
948+
// Fail to resolve all other assets
949+
return DocumentationServer.Message(
950+
type: "resolve-reference-response",
951+
payload: try JSONEncoder().encode(
952+
OutOfProcessReferenceResolver.Response.errorMessage("Unexpected topic request")
953+
)
954+
)
955+
}
956+
}
957+
} catch {
958+
XCTFail(error.localizedDescription)
959+
return nil
960+
}
961+
}
962+
963+
server.register(service: mockLinkResolvingService)
964+
965+
try processAndAssert(request: request, linkResolvingServer: server) { message in
966+
XCTAssertEqual(message.type, "convert-response")
967+
XCTAssertEqual(message.identifier, "test-identifier-response")
968+
969+
let response = try JSONDecoder().decode(
970+
ConvertResponse.self, from: XCTUnwrap(message.payload)
971+
)
972+
973+
XCTAssertEqual(response.renderNodes.count, 1)
974+
let data = try XCTUnwrap(response.renderNodes.first)
975+
let renderNode = try JSONDecoder().decode(RenderNode.self, from: data)
976+
977+
let beforeIdentifier = RenderReferenceIdentifier("before.swift")
978+
let afterIdentifier = RenderReferenceIdentifier("after.swift")
979+
980+
XCTAssertEqual(
981+
renderNode.references["before.swift"] as? FileReference,
982+
FileReference(identifier: beforeIdentifier, fileName: "Something.swift", fileType: "swift", syntax: "swift", content: [
983+
"// This is an example swift file",
984+
], highlights: [])
985+
)
986+
XCTAssertEqual(
987+
renderNode.references["after.swift"] as? FileReference,
988+
FileReference(identifier: afterIdentifier, fileName: "Something.swift", fileType: "swift", syntax: "swift", content: [
989+
"// This is an example swift file",
990+
"let something = 0",
991+
], highlights: [.init(line: 2)])
992+
)
993+
994+
let stepsSection = try XCTUnwrap(renderNode.sections.compactMap { $0 as? TutorialSectionsRenderSection }.first?.tasks.first?.stepsSection)
995+
XCTAssertEqual(stepsSection.count, 2)
996+
if case .step(let step) = stepsSection.first {
997+
XCTAssertEqual(step.code, beforeIdentifier)
998+
} else {
999+
XCTFail("Unexpected kind of step")
1000+
}
1001+
1002+
if case .step(let step) = stepsSection.last {
1003+
XCTAssertEqual(step.code, afterIdentifier)
1004+
} else {
1005+
XCTFail("Unexpected kind of step")
1006+
}
1007+
}
1008+
}
1009+
8381010
func testConvertArticleWithImageReferencesAndDetailedGridLinks() throws {
8391011
let articleData = try XCTUnwrap("""
8401012
# First article

Tests/SwiftDocCTests/Model/LineHighlighterTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class LineHighlighterTests: XCTestCase {
6868
let tutorialReference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/tutorials/Line-Highlighter-Tests/Tutorial", fragment: nil, sourceLanguage: .swift)
6969
let tutorial = try context.entity(with: tutorialReference).semantic as! Tutorial
7070
let section = tutorial.sections.first!
71-
return LineHighlighter(context: context, tutorialSection: section).highlights
71+
return LineHighlighter(context: context, tutorialSection: section, tutorialReference: tutorialReference).highlights
7272
}
7373

7474
func testNoSteps() throws {

0 commit comments

Comments
 (0)