Skip to content

Commit 8b1022f

Browse files
committed
Add basic merge command to combine documentation archives
rdar://114730477
1 parent 95a24ea commit 8b1022f

File tree

21 files changed

+954
-88
lines changed

21 files changed

+954
-88
lines changed

Sources/SwiftDocC/Converter/RenderNode+Coding.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information

Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information
@@ -23,45 +23,74 @@ import SymbolKit
2323
/// `Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderIndex.spec.json`.
2424
public struct RenderIndex: Codable, Equatable {
2525
/// The current schema version of the Index JSON spec.
26-
public static let currentSchemaVersion = SemanticVersion(major: 0, minor: 1, patch: 1)
26+
public static let currentSchemaVersion = SemanticVersion(major: 0, minor: 1, patch: 2)
2727

2828
/// The version of the RenderIndex spec that was followed when creating this index.
2929
public let schemaVersion: SemanticVersion
3030

3131
/// A mapping of interface languages to the index nodes they contain.
32-
public let interfaceLanguages: [String: [Node]]
32+
public private(set) var interfaceLanguages: [String: [Node]]
3333

3434
/// The values of the image references used in the documentation index.
3535
public private(set) var references: [String: ImageReference]
3636

37+
/// The unique identifiers of the archives that are included in the documentation index.
38+
public private(set) var includedArchiveIdentifiers: [String]
39+
3740
enum CodingKeys: CodingKey {
3841
case schemaVersion
3942
case interfaceLanguages
4043
case references
44+
case includedArchiveIdentifiers
4145
}
4246

4347
/// Creates a new render index with the given interface language to node mapping.
4448
public init(
4549
interfaceLanguages: [String: [Node]],
46-
references: [String: ImageReference] = [:]
50+
references: [String: ImageReference] = [:],
51+
includedArchiveIdentifiers: [String]
4752
) {
4853
self.schemaVersion = Self.currentSchemaVersion
4954
self.interfaceLanguages = interfaceLanguages
5055
self.references = references
56+
self.includedArchiveIdentifiers = includedArchiveIdentifiers
5157
}
5258

5359
public func encode(to encoder: Encoder) throws {
5460
var container = encoder.container(keyedBy: CodingKeys.self)
5561
try container.encode(self.schemaVersion, forKey: .schemaVersion)
5662
try container.encode(self.interfaceLanguages, forKey: .interfaceLanguages)
5763
try container.encodeIfNotEmpty(self.references, forKey: .references)
64+
try container.encodeIfNotEmpty(self.includedArchiveIdentifiers, forKey: .includedArchiveIdentifiers)
5865
}
5966

6067
public init(from decoder: Decoder) throws {
6168
let container = try decoder.container(keyedBy: CodingKeys.self)
6269
self.schemaVersion = try container.decode(SemanticVersion.self, forKey: .schemaVersion)
6370
self.interfaceLanguages = try container.decode([String : [RenderIndex.Node]].self, forKey: .interfaceLanguages)
6471
self.references = try container.decodeIfPresent([String : ImageReference].self, forKey: .references) ?? [:]
72+
self.includedArchiveIdentifiers = try container.decodeIfPresent([String].self.self, forKey: .includedArchiveIdentifiers) ?? []
73+
}
74+
75+
public mutating func merge(_ other: RenderIndex) throws {
76+
for (languageID, nodes) in other.interfaceLanguages {
77+
interfaceLanguages[languageID, default: []].append(contentsOf: nodes)
78+
}
79+
80+
try references.merge(other.references) { _, new in throw MergeError.referenceCollision(new.identifier.identifier) }
81+
82+
includedArchiveIdentifiers.append(contentsOf: other.includedArchiveIdentifiers)
83+
}
84+
85+
enum MergeError: DescribedError {
86+
case referenceCollision(String)
87+
88+
var errorDescription: String {
89+
switch self {
90+
case .referenceCollision(let reference):
91+
return "Collision merging image references. Reference \(reference.singleQuoted) exists in more than one input archive."
92+
}
93+
}
6594
}
6695
}
6796

@@ -254,7 +283,8 @@ extension RenderIndex {
254283
},
255284
uniquingKeysWith: +
256285
),
257-
references: builder.iconReferences
286+
references: builder.iconReferences,
287+
includedArchiveIdentifiers: [builder.bundleIdentifier]
258288
)
259289
}
260290
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information

Sources/SwiftDocC/Model/Rendering/Tutorial/References/DownloadReference.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information

Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderIndex.spec.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"openapi": "3.0.0",
33
"info": {
44
"description": "Specification of the Swift-DocC Index.json file.",
5-
"version": "0.1.0",
5+
"version": "0.1.2",
66
"title": "RenderIndex"
77
},
88
"paths": {},
@@ -33,6 +33,12 @@
3333
"$ref": "#/components/schemas/ImageRenderReference"
3434
}
3535
},
36+
"includedArchiveIdentifiers": {
37+
"type": "array",
38+
"items": {
39+
"type": "string"
40+
}
41+
}
3642
}
3743
},
3844
"Node": {

Sources/SwiftDocCTestUtilities/FilesAndFolders.swift

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

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

77
See https://swift.org/LICENSE.txt for license information
@@ -251,3 +251,134 @@ extension XCTestCase {
251251
return temporaryDirectory
252252
}
253253
}
254+
255+
// MARK: Dump
256+
257+
extension Folder {
258+
/// Creates a file and folder hierarchy from the given file paths.
259+
///
260+
/// ## Example
261+
/// For example, `makeStructure(filePaths: ["one/two/a.json", "one/two/b.json"])` creates the following files and folders.
262+
/// ```
263+
/// one/
264+
/// ╰─ two/
265+
/// ├─ a.json
266+
/// ╰─ b.json
267+
/// ```
268+
///
269+
/// - Note: If there are more than one first path component in the provided paths, the return value will contain more than one element.
270+
public static func makeStructure(
271+
filePaths: [String],
272+
isEmptyDirectoryCheck: (String) -> Bool = { _ in false }
273+
) -> [File] {
274+
guard !filePaths.isEmpty else {
275+
return []
276+
}
277+
typealias Path = [String]
278+
279+
func _makeStructure(paths: [Path], accumulatedBasePath: String) -> [File] {
280+
assert(paths.allSatisfy { !$0.isEmpty })
281+
282+
let grouped = [String: [Path]](grouping: paths, by: { $0.first! }).mapValues {
283+
$0.map { Array($0.dropFirst()) }
284+
}
285+
286+
return grouped.map { pathComponent, remaining in
287+
let absolutePath = "\(accumulatedBasePath)/\(pathComponent)"
288+
if remaining == [[]] && !isEmptyDirectoryCheck(absolutePath) {
289+
return TextFile(name: pathComponent, utf8Content: "")
290+
} else {
291+
return Folder(name: pathComponent, content: _makeStructure(paths: remaining.filter { !$0.isEmpty }, accumulatedBasePath: absolutePath))
292+
}
293+
}
294+
}
295+
296+
if filePaths.allSatisfy({ $0.hasPrefix("/")}) {
297+
let subPaths = filePaths.map { $0.dropFirst() }.filter { !$0.isEmpty }
298+
return [Folder(name: "", content: _makeStructure(paths: subPaths.map { String($0).components(separatedBy: CharacterSet(charactersIn: "/")) }, accumulatedBasePath: ""))]
299+
}
300+
301+
return _makeStructure(paths: filePaths.map { $0.components(separatedBy: CharacterSet(charactersIn: "/")) }, accumulatedBasePath: "")
302+
}
303+
}
304+
305+
/// A node in a tree structure that can be printed into a visual representation for debugging.
306+
private struct DumpableNode {
307+
var name: String
308+
var children: [DumpableNode]?
309+
310+
init(_ file: File) {
311+
if let folder = file as? Folder {
312+
name = file.name
313+
children = folder.content.map { DumpableNode($0) }
314+
} else {
315+
name = file.name
316+
children = nil
317+
}
318+
}
319+
}
320+
321+
extension File {
322+
/// Returns a stable string representation of the file and folder hierarchy that can be checked in tests.
323+
///
324+
/// ## Example
325+
/// ```swift
326+
/// Folder(name: "one", content: [
327+
/// Folder(name: "two", content: [
328+
/// TextFile(name: "a.json", utf8Content: ""),
329+
/// TextFile(name: "b.json", utf8Content: ""),
330+
/// ])
331+
/// ])
332+
/// ```
333+
/// The string `dump()` for the folder hierarchy above is shown below:
334+
/// ```
335+
/// one/
336+
/// ╰─ two/
337+
/// ├─ a.json
338+
/// ╰─ b.json
339+
/// ```
340+
public func dump() -> String {
341+
Self.dump(.init(self))
342+
.trimmingCharacters(in: .newlines) // remove the trailing newline
343+
}
344+
345+
private static func dump(_ node: DumpableNode, decorator: String = "") -> String {
346+
var result = ""
347+
result.append(decorator)
348+
if !decorator.isEmpty {
349+
result.append("")
350+
}
351+
result.append(node.name)
352+
guard let children = node.children else {
353+
return result + "\n"
354+
}
355+
result.append("/\n")
356+
357+
let sortedChildren = children.sorted(by: { lhs, rhs in
358+
// Sort files before folders if the folder name is a prefix of the file name
359+
switch (lhs.children, rhs.children) {
360+
case (nil, nil):
361+
return lhs.name < rhs.name
362+
case (nil, _) where lhs.name.hasPrefix(rhs.name):
363+
return true
364+
case (_, nil) where rhs.name.hasPrefix(lhs.name):
365+
return false
366+
default:
367+
return lhs.name < rhs.name
368+
}
369+
})
370+
371+
for (index, child) in sortedChildren.enumerated() {
372+
var decorator = decorator
373+
if decorator.hasSuffix("") {
374+
decorator = decorator.dropLast() + ""
375+
}
376+
if decorator.hasSuffix("") {
377+
decorator = decorator.dropLast() + " "
378+
}
379+
let newDecorator = decorator + (index == sortedChildren.count-1 ? "" : "")
380+
result.append(dump(child, decorator: newDecorator))
381+
}
382+
return result
383+
}
384+
}

Sources/SwiftDocCTestUtilities/TestFileSystem.swift

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProv
5454

5555
/// Thread safe access to the file system.
5656
private var filesLock = NSRecursiveLock()
57-
57+
5858
/// A plain index of paths and their contents.
5959
var files = [String: Data]()
6060

@@ -82,7 +82,7 @@ public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProv
8282
let files = try addFolder(folder)
8383

8484
func asCatalog(_ file: File) -> Folder? {
85-
if let folder = file as? Folder, folder.absoluteURL.pathExtension == "docc" {
85+
if let folder = file as? Folder, URL(fileURLWithPath: folder.name).pathExtension == "docc" {
8686
return folder
8787
}
8888
return nil
@@ -100,7 +100,7 @@ public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProv
100100
let info = try DocumentationBundle.Info(
101101
from: try catalog.recursiveContent.mapFirst(where: { $0 as? InfoPlist })?.data(),
102102
bundleDiscoveryOptions: nil,
103-
derivedDisplayName: catalog.absoluteURL.deletingPathExtension().lastPathComponent
103+
derivedDisplayName: URL(fileURLWithPath: catalog.name).deletingPathExtension().lastPathComponent
104104
)
105105

106106
let bundle = DocumentationBundle(
@@ -334,11 +334,30 @@ public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProv
334334
}
335335
}
336336

337-
func dump() -> String {
337+
/// Returns a stable string representation of the file system from a given subpath.
338+
///
339+
/// - Parameter path: The path to the sub hierarchy to dump to a string representation.
340+
/// - Returns: A stable string representation that can be checked in tests.
341+
public func dump(subHierarchyFrom path: String = "/") -> String {
338342
filesLock.lock()
339343
defer { filesLock.unlock() }
340-
341-
return files.keys.sorted().joined(separator: "\n")
344+
345+
let relevantFilePaths: [String]
346+
if path == "/" {
347+
relevantFilePaths = Array(files.keys)
348+
} else {
349+
let lengthToRemove = path.distance(from: path.startIndex, to: path.lastIndex(of: "/")!) + 1
350+
351+
relevantFilePaths = files.keys
352+
.filter { $0.hasPrefix(path) }
353+
.map { String($0.dropFirst(lengthToRemove)) }
354+
}
355+
return Folder.makeStructure(
356+
filePaths: relevantFilePaths,
357+
isEmptyDirectoryCheck: { files[$0] == Self.folderFixtureData }
358+
)
359+
.map { $0.dump() }
360+
.joined(separator: "\n")
342361
}
343362

344363
// This is a convenience utility for testing, not FileManagerProtocol API

0 commit comments

Comments
 (0)