diff --git a/Changelog.md b/Changelog.md index 45cfc8cd..8625e8d5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for generating documentation for + extensions to external types. + #230 by @Lukas-Stuehrk and @mattt. - Added end-to-end tests for command-line interface. #199 by @MaxDesiatov and @mattt. - Added `--minimum-access-level` option to `generate` and `coverage` commands. @@ -59,7 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #159 by @mattt. - Fixed relationship diagram to prevent linking to unknown symbols. #178 by @MattKiazyk. -- Fixed problems in CommonMark output related to escaping emoji shortcode. +- Fixed problems in CommonMark output related to escaping emoji shortcode. #167 by @mattt. ### Changed diff --git a/Sources/SwiftDoc/Identifier.swift b/Sources/SwiftDoc/Identifier.swift index bd9bd2d2..8c46236e 100644 --- a/Sources/SwiftDoc/Identifier.swift +++ b/Sources/SwiftDoc/Identifier.swift @@ -1,9 +1,24 @@ public struct Identifier: Hashable { - public let pathComponents: [String] + public let context: [String] public let name: String + public let pathComponents: [String] + + public init(context: [String], name: String) { + self.context = context + self.name = name + self.pathComponents = context + CollectionOfOne(name) + } public func matches(_ string: String) -> Bool { - (pathComponents + CollectionOfOne(name)).reversed().starts(with: string.split(separator: ".").map { String($0) }.reversed()) + return matches(string.split(separator: ".")) + } + + public func matches(_ pathComponents: [Substring]) -> Bool { + return matches(pathComponents.map(String.init)) + } + + public func matches(_ pathComponents: [String]) -> Bool { + return self.pathComponents.ends(with: pathComponents) } } @@ -11,6 +26,16 @@ public struct Identifier: Hashable { extension Identifier: CustomStringConvertible { public var description: String { - (pathComponents + CollectionOfOne(name)).joined(separator: ".") + pathComponents.joined(separator: ".") + } +} + +fileprivate extension Array { + func ends(with possibleSuffix: PossibleSuffix) -> Bool + where PossibleSuffix : Sequence, + Self.Element == PossibleSuffix.Element, + Self.Element: Equatable + { + reversed().starts(with: possibleSuffix) } } diff --git a/Sources/SwiftDoc/Interface.swift b/Sources/SwiftDoc/Interface.swift index 53e020d0..f68972f1 100644 --- a/Sources/SwiftDoc/Interface.swift +++ b/Sources/SwiftDoc/Interface.swift @@ -10,16 +10,18 @@ public final class Interface { self.imports = imports self.symbols = symbols - self.symbolsGroupedByIdentifier = Dictionary(grouping: symbols, by: { $0.id }) - self.symbolsGroupedByQualifiedName = Dictionary(grouping: symbols, by: { $0.id.description }) - self.topLevelSymbols = symbols.filter { $0.api is Type || $0.id.pathComponents.isEmpty } + let symbolsGroupedByIdentifier = Dictionary(grouping: symbols, by: { $0.id }) + let symbolsGroupedByQualifiedName = Dictionary(grouping: symbols, by: { $0.id.description }) + + self.symbolsGroupedByIdentifier = symbolsGroupedByIdentifier + self.symbolsGroupedByQualifiedName = symbolsGroupedByQualifiedName + self.topLevelSymbols = symbols.filter { $0.api is Type || $0.id.context.isEmpty } self.relationships = { let extensionsByExtendedType: [String: [Extension]] = Dictionary(grouping: symbols.flatMap { $0.context.compactMap { $0 as? Extension } }, by: { $0.extendedType }) var relationships: Set = [] for symbol in symbols { - let lastDeclarationScope = symbol.context.last(where: { $0 is Extension || $0 is Symbol }) if let container = lastDeclarationScope as? Symbol { @@ -40,8 +42,7 @@ public final class Interface { } if let `extension` = lastDeclarationScope as? Extension { - if let extended = symbols.first(where: { $0.api is Type && $0.id.matches(`extension`.extendedType) }) { - + for extended in symbolsGroupedByIdentifier.named(`extension`.extendedType, resolvingTypealiases: true) { let predicate: Relationship.Predicate switch extended.api { case is Protocol: @@ -66,7 +67,7 @@ public final class Interface { inheritedTypeNames = Set(inheritedTypeNames.flatMap { $0.split(separator: "&").map { $0.trimmingCharacters(in: .whitespaces) } }) for name in inheritedTypeNames { - let inheritedTypes = symbols.filter({ ($0.api is Class || $0.api is Protocol) && $0.id.description == name }) + let inheritedTypes = symbolsGroupedByIdentifier.named(name, resolvingTypealiases: true).filter({ ($0.api is Class || $0.api is Protocol) && $0.id.description == name }) if inheritedTypes.isEmpty { let inherited = Symbol(api: Unknown(name: name), context: [], declaration: [], documentation: nil, sourceRange: nil) relationships.insert(Relationship(subject: symbol, predicate: .conformsTo, object: inherited)) @@ -115,7 +116,6 @@ public final class Interface { } return classClusters - } public let relationships: [Relationship] @@ -159,4 +159,37 @@ public final class Interface { public func defaultImplementations(of symbol: Symbol) -> [Symbol] { return relationshipsByObject[symbol.id]?.filter { $0.predicate == .defaultImplementationOf }.map { $0.subject }.sorted() ?? [] } + + // MARK: - + + public func symbols(named name: String, resolvingTypealiases: Bool) -> [Symbol] { + symbolsGroupedByIdentifier.named(name, resolvingTypealiases: resolvingTypealiases) + } +} + +fileprivate extension Dictionary where Key == Identifier, Value == [Symbol] { + func named(_ name: String, resolvingTypealiases: Bool) -> [Symbol] { + var pathComponents: [String] = [] + for component in name.split(separator: ".") { + pathComponents.append("\(component)") + guard resolvingTypealiases else { continue } + + if let symbols = first(where: { $0.key.pathComponents == pathComponents })?.value, + let symbol = symbols.first(where: { $0.api is Typealias }), + let `typealias` = symbol.api as? Typealias, + let initializedType = `typealias`.initializedType + { + let initializedTypePathComponents = initializedType.split(separator: ".") + let candidates = keys.filter { $0.matches(initializedTypePathComponents) } + + if let id = candidates.max(by: { $0.pathComponents.count > $1.pathComponents.count }) { + pathComponents = id.pathComponents + } else { + return [] + } + } + } + + return first(where: { $0.key.pathComponents == pathComponents })?.value ?? [] + } } diff --git a/Sources/SwiftDoc/Symbol.swift b/Sources/SwiftDoc/Symbol.swift index 2590720b..66e1c659 100644 --- a/Sources/SwiftDoc/Symbol.swift +++ b/Sources/SwiftDoc/Symbol.swift @@ -6,6 +6,7 @@ import struct Highlighter.Token public final class Symbol { public typealias ID = Identifier + public let id: ID public let api: API public let context: [Contextual] public let declaration: [Token] @@ -19,6 +20,9 @@ public final class Symbol { public private(set) lazy var conditions: [CompilationCondition] = context.compactMap { $0 as? CompilationCondition } init(api: API, context: [Contextual], declaration: [Token], documentation: Documentation?, sourceRange: SourceRange?) { + self.id = Identifier(context: context.compactMap { + ($0 as? Symbol)?.name ?? ($0 as? Extension)?.extendedType + }, name: api.name) self.api = api self.context = context self.declaration = declaration @@ -30,12 +34,6 @@ public final class Symbol { return api.name } - public private(set) lazy var id: ID = { - Identifier(pathComponents: context.compactMap { - ($0 as? Symbol)?.name ?? ($0 as? Extension)?.extendedType - }, name: name) - }() - public var isPublic: Bool { if api is Unknown { return true @@ -329,3 +327,9 @@ extension Symbol: Codable { try container.encode(sourceRange, forKey: .sourceRange) } } + +extension Symbol: CustomDebugStringConvertible { + public var debugDescription: String { + return "\(self.declaration.map { $0.text }.joined())" + } +} diff --git a/Sources/swift-doc/Subcommands/Generate.swift b/Sources/swift-doc/Subcommands/Generate.swift index cca17a59..b1f16a78 100644 --- a/Sources/swift-doc/Subcommands/Generate.swift +++ b/Sources/swift-doc/Subcommands/Generate.swift @@ -75,6 +75,17 @@ extension SwiftDoc { } } + // Extensions on external types. + var symbolsByExternalType: [String: [Symbol]] = [:] + for symbol in module.interface.symbols.filter(symbolFilter) { + guard let extensionDeclaration = symbol.context.first as? Extension, symbol.context.count == 1 else { continue } + guard module.interface.symbols(named: extensionDeclaration.extendedType, resolvingTypealiases: true).isEmpty else { continue } + symbolsByExternalType[extensionDeclaration.extendedType, default: []] += [symbol] + } + for (typeName, symbols) in symbolsByExternalType { + pages[route(for: typeName)] = ExternalTypePage(module: module, externalType: typeName, symbols: symbols, baseURL: baseURL) + } + for (name, symbols) in globals { pages[route(for: name)] = GlobalPage(module: module, name: name, symbols: symbols, baseURL: baseURL) } @@ -101,11 +112,11 @@ extension SwiftDoc { } else { switch format { case .commonmark: - pages["Home"] = HomePage(module: module, baseURL: baseURL, symbolFilter: symbolFilter) - pages["_Sidebar"] = SidebarPage(module: module, baseURL: baseURL, symbolFilter: symbolFilter) + pages["Home"] = HomePage(module: module, externalTypes: Array(symbolsByExternalType.keys), baseURL: baseURL, symbolFilter: symbolFilter) + pages["_Sidebar"] = SidebarPage(module: module, externalTypes: Set(symbolsByExternalType.keys), baseURL: baseURL, symbolFilter: symbolFilter) pages["_Footer"] = FooterPage(baseURL: baseURL) case .html: - pages["Home"] = HomePage(module: module, baseURL: baseURL, symbolFilter: symbolFilter) + pages["Home"] = HomePage(module: module, externalTypes: Array(symbolsByExternalType.keys), baseURL: baseURL, symbolFilter: symbolFilter) } try pages.map { $0 }.parallelForEach { diff --git a/Sources/swift-doc/Supporting Types/Pages/ExternalTypePage.swift b/Sources/swift-doc/Supporting Types/Pages/ExternalTypePage.swift new file mode 100644 index 00000000..febc3598 --- /dev/null +++ b/Sources/swift-doc/Supporting Types/Pages/ExternalTypePage.swift @@ -0,0 +1,87 @@ +import CommonMarkBuilder +import SwiftDoc +import HypertextLiteral +import SwiftMarkup +import SwiftSemantics + +struct ExternalTypePage: Page { + + let module: Module + let externalType: String + let baseURL: String + + let typealiases: [Symbol] + let initializers: [Symbol] + let properties: [Symbol] + let methods: [Symbol] + + init(module: Module, externalType: String, symbols: [Symbol], baseURL: String) { + self.module = module + self.externalType = externalType + self.baseURL = baseURL + + self.typealiases = symbols.filter { $0.api is Typealias } + self.initializers = symbols.filter { $0.api is Initializer } + self.properties = symbols.filter { $0.api is Variable } + self.methods = symbols.filter { $0.api is Function } + } + + var title: String { externalType } + + var sections: [(title: String, members: [Symbol])] { + return [ + ("Nested Type Aliases", typealiases), + ("Initializers", initializers), + ("Properties", properties), + ("Methods", methods), + ].filter { !$0.members.isEmpty } + } + + var document: CommonMark.Document { + Document { + Heading { "Extensions on \(externalType)" } + ForEach(in: sections) { section -> BlockConvertible in + Section { + Heading { section.title } + + Section { + ForEach(in: section.members) { member in + Heading { + Code { member.name } + } + Documentation(for: member, in: module, baseURL: baseURL) + } + } + } + } + } + } + var html: HypertextLiteral.HTML { + #""" +

+ Extensions on + \#(externalType) +

+ \#(sections.map { section -> HypertextLiteral.HTML in + #""" +
+

\#(section.title)

+ + \#(section.members.map { member -> HypertextLiteral.HTML in + let descriptor = String(describing: type(of: member.api)).lowercased() + + return #""" +
+

+ \#(softbreak(member.name)) +

+ \#(Documentation(for: member, in: module, baseURL: baseURL).html) +
+ """# + }) +
+ """# + }) + """# + } +} diff --git a/Sources/swift-doc/Supporting Types/Pages/HomePage.swift b/Sources/swift-doc/Supporting Types/Pages/HomePage.swift index 27090a08..dc781d53 100644 --- a/Sources/swift-doc/Supporting Types/Pages/HomePage.swift +++ b/Sources/swift-doc/Supporting Types/Pages/HomePage.swift @@ -16,10 +16,14 @@ struct HomePage: Page { var globalFunctions: [Symbol] = [] var globalVariables: [Symbol] = [] - init(module: Module, baseURL: String, symbolFilter: (Symbol) -> Bool) { + let externalTypes: [String] + + init(module: Module, externalTypes: [String], baseURL: String, symbolFilter: (Symbol) -> Bool) { self.module = module self.baseURL = baseURL + self.externalTypes = externalTypes + for symbol in module.interface.topLevelSymbols.filter(symbolFilter) { switch symbol.api { case is Class: @@ -70,6 +74,18 @@ struct HomePage: Page { } } } + + if !externalTypes.isEmpty { + Heading { "Extensions"} + + List(of: externalTypes.sorted()) { typeName in + List.Item { + Paragraph { + Link(urlString: path(for: route(for: typeName), with: baseURL), text: typeName) + } + } + } + } } } @@ -95,6 +111,23 @@ struct HomePage: Page { """# }) + \#((externalTypes.isEmpty ? "" : + #""" +
+

Extensions

+
+ \#(externalTypes.sorted().map { + #""" +
+ \#($0) +
+
+ """# as HypertextLiteral.HTML + }) +
+
+ """# + ) as HypertextLiteral.HTML) """# } } diff --git a/Sources/swift-doc/Supporting Types/Pages/SidebarPage.swift b/Sources/swift-doc/Supporting Types/Pages/SidebarPage.swift index cbbce3b0..4a715dc3 100644 --- a/Sources/swift-doc/Supporting Types/Pages/SidebarPage.swift +++ b/Sources/swift-doc/Supporting Types/Pages/SidebarPage.swift @@ -14,10 +14,14 @@ struct SidebarPage: Page { var globalFunctionNames: Set = [] var globalVariableNames: Set = [] - init(module: Module, baseURL: String, symbolFilter: (Symbol) -> Bool) { + let externalTypes: Set + + init(module: Module, externalTypes: Set, baseURL: String, symbolFilter: (Symbol) -> Bool) { self.module = module self.baseURL = baseURL + self.externalTypes = externalTypes + for symbol in module.interface.topLevelSymbols.filter(symbolFilter) { switch symbol.api { case is Class: @@ -55,7 +59,8 @@ struct SidebarPage: Page { ("Global Typealiases", globalTypealiasNames), ("Global Variables",globalVariableNames), ("Global Functions", globalFunctionNames), - ("Operators", operatorNames) + ("Operators", operatorNames), + ("Extensions", externalTypes), ] as [(title: String, names: Set)] ).filter { !$0.names.isEmpty }) { section in // FIXME: This should be an HTML block diff --git a/Tests/SwiftDocTests/InterfaceTypeTests.swift b/Tests/SwiftDocTests/InterfaceTypeTests.swift index 559f1cc7..58ee31c3 100644 --- a/Tests/SwiftDocTests/InterfaceTypeTests.swift +++ b/Tests/SwiftDocTests/InterfaceTypeTests.swift @@ -266,4 +266,97 @@ final class InterfaceTypeTests: XCTestCase { XCTAssertEqual(defaultImplementations[0].name, "someMethod()") XCTAssertEqual(defaultImplementations[1].name, "someOtherMethod()") } + + func testExternalSymbols() throws { + let source = #""" + import UIKit + + public class SomeClass { + public struct InnerObject { } + + typealias ActuallyExternal = UIView + + typealias ActuallyInternal = InnerStruct + + struct InnerStruct {} + } + + public typealias MyClass = SomeClass + + public class AnotherClass { + typealias PublicClass = SomeClass + } + + public typealias ExternalClass = UIGestureRecognizer + """# + + + let url = try temporaryFile(contents: source) + let sourceFile = try SourceFile(file: url, relativeTo: url.deletingLastPathComponent()) + let module = Module(name: "Module", sourceFiles: [sourceFile]) + + XCTAssertEqual(module.interface.symbols(named: "SomeClass", resolvingTypealiases: true).first?.name, "SomeClass") + XCTAssertEqual(module.interface.symbols(named: "SomeClass", resolvingTypealiases: false).first?.name, "SomeClass") + + XCTAssertEqual(module.interface.symbols(named: "SomeClass.InnerObject", resolvingTypealiases: true).first?.name, "InnerObject") + XCTAssertEqual(module.interface.symbols(named: "SomeClass.InnerObject", resolvingTypealiases: false).first?.name, "InnerObject") + + XCTAssertEqual(module.interface.symbols(named: "SomeClass.ActuallyInternal", resolvingTypealiases: true).first?.name, "InnerStruct") + XCTAssertEqual(module.interface.symbols(named: "SomeClass.ActuallyInternal", resolvingTypealiases: false).first?.name, "ActuallyInternal") + + XCTAssertEqual(module.interface.symbols(named: "MyClass", resolvingTypealiases: true).first?.name, "SomeClass") + XCTAssertEqual(module.interface.symbols(named: "MyClass", resolvingTypealiases: false).first?.name, "MyClass") + + XCTAssertEqual(module.interface.symbols(named: "MyClass.InnerObject", resolvingTypealiases: true).first?.name, "InnerObject") + XCTAssertNil(module.interface.symbols(named: "MyClass.InnerObject", resolvingTypealiases: false).first) + + XCTAssertEqual(module.interface.symbols(named: "AnotherClass.PublicClass", resolvingTypealiases: true).first?.name, "SomeClass") + XCTAssertTrue(module.interface.symbols(named: "AnotherClass.PublicClass", resolvingTypealiases: false).first?.api is Typealias) + + XCTAssertEqual(module.interface.symbols(named: "AnotherClass.PublicClass.ActuallyInternal", resolvingTypealiases: true).first?.name, "InnerStruct") + XCTAssertNil(module.interface.symbols(named: "AnotherClass.PublicClass.ActuallyInternal", resolvingTypealiases: false).first) + + XCTAssertNil(module.interface.symbols(named: "ExternalClass", resolvingTypealiases: true).first) + XCTAssertTrue(module.interface.symbols(named: "ExternalClass", resolvingTypealiases: false).first?.api is Typealias) + + XCTAssertNil(module.interface.symbols(named: "ExternalClass.State", resolvingTypealiases: true).first) + XCTAssertNil(module.interface.symbols(named: "ExternalClass.State", resolvingTypealiases: false).first) + + XCTAssertNil(module.interface.symbols(named: "SomeClass.ActuallyExternal", resolvingTypealiases: true).first) + XCTAssertTrue(module.interface.symbols(named: "SomeClass.ActuallyExternal", resolvingTypealiases: false).first?.api is Typealias) + + XCTAssertNil(module.interface.symbols(named: "UIGestureRecognizer", resolvingTypealiases: true).first) + XCTAssertNil(module.interface.symbols(named: "UIGestureRecognizer", resolvingTypealiases: false).first) + + XCTAssertNil(module.interface.symbols(named: "UIGestureRecognizer.State", resolvingTypealiases: true).first) + XCTAssertNil(module.interface.symbols(named: "UIGestureRecognizer.State", resolvingTypealiases: false).first) + } + + public func testMembersOfTypealiasedSymbols() throws { + let source = #""" + public class SomeClass { + public func someMethod() { } + } + + public typealias OtherClass = SomeClass + + public extension OtherClass { + func someExtensionMethod() { } + } + """# + + + let url = try temporaryFile(contents: source) + let sourceFile = try SourceFile(file: url, relativeTo: url.deletingLastPathComponent()) + let module = Module(name: "Module", sourceFiles: [sourceFile]) + + XCTAssertEqual(module.interface.symbols.count, 4) + + let someClass = module.interface.symbols[0] + XCTAssertEqual(someClass.name, "SomeClass") + let members = module.interface.members(of: someClass) + XCTAssertEqual(2, members.count) + XCTAssertEqual(members[0].name, "someMethod()") + XCTAssertEqual(members[1].name, "someExtensionMethod()") + } }