From dd9c44c0e69bcf07d3ab3bf88b7f19765f69eff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20R=C3=B6nnqvist?= Date: Fri, 5 May 2023 14:05:29 -0700 Subject: [PATCH] Better support for relative links when multiple symbols in the hierarchy have the same name (#578) * Walk up the path hierarchy if links fail to resolve in a specific scope rdar://108672152 Also, check the module's scope if the link couldn't otherwise resolve rdar://76252171 * Fix test linking to heading that doesn't exist * Update expression that was very slow to type check * Fix warning about mutating a captured sendable value * Remove outdated comment about adding more test assertions * Update test for old link resolver implementation --- .../Link Resolution/PathHierarchy.swift | 296 +++---- .../Rendering/Symbol/ConformanceSection.swift | 12 +- .../DocumentationContextTests.swift | 77 ++ .../Infrastructure/PathHierarchyTests.swift | 48 ++ .../ReferenceResolverTests.swift | 18 +- .../Something.symbols.json | 729 ++++++++++++++++++ .../JSONEncodingRenderNodeWriterTests.swift | 16 +- 7 files changed, 1033 insertions(+), 163 deletions(-) create mode 100644 Tests/SwiftDocCTests/Test Bundles/SymbolsWithSameNameAsModule.docc/Something.symbols.json diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift index ab0bc88c4e..c27211ba43 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift @@ -298,7 +298,7 @@ struct PathHierarchy { return newReference } - + // MARK: Finding elements in the hierarchy /// Attempts to find an element in the path hierarchy for a given path relative to another element. @@ -310,7 +310,7 @@ struct PathHierarchy { /// - Returns: Returns the unique identifier for the found match or raises an error if no match can be found. /// - Throws: Raises a ``PathHierarchy/Error`` if no match can be found. func find(path rawPath: String, parent: ResolvedIdentifier? = nil, onlyFindSymbols: Bool) throws -> ResolvedIdentifier { - let node = try findNode(path: rawPath, parent: parent, onlyFindSymbols: onlyFindSymbols) + let node = try findNode(path: rawPath, parentID: parent, onlyFindSymbols: onlyFindSymbols) if node.identifier == nil { throw Error.unfindableMatch(node) } @@ -320,22 +320,143 @@ struct PathHierarchy { return node.identifier } - private func findNode(path rawPath: String, parent: ResolvedIdentifier?, onlyFindSymbols: Bool) throws -> Node { + private func findNode(path rawPath: String, parentID: ResolvedIdentifier?, onlyFindSymbols: Bool) throws -> Node { // The search for a documentation element can be though of as 3 steps: - // First, parse the path into structured path components. + // - First, parse the path into structured path components. + // - Second, find nodes that match the beginning of the path as starting points for the search + // - Third, traverse the hierarchy from those starting points to search for the node. let (path, isAbsolute) = Self.parse(path: rawPath) guard !path.isEmpty else { throw Error.notFound(remaining: [], availableChildren: []) } - var parsedPathForError: [PathComponent] { + var remaining = path[...] + + // If the first path component is "tutorials" or "documentation" then use that information to narrow the search. + let isKnownTutorialPath = remaining.first!.full == NodeURLGenerator.Path.tutorialsFolderName + let isKnownDocumentationPath = remaining.first!.full == NodeURLGenerator.Path.documentationFolderName + if isKnownDocumentationPath || isKnownTutorialPath { + // Skip this component since it isn't represented in the path hierarchy. + remaining.removeFirst() + } + + guard let firstComponent = remaining.first else { + throw Error.notFound(remaining: [], availableChildren: []) + } + + // A function to avoid eagerly computing the full path unless it needs to be presented in an error message. + func parsedPathForError() -> [PathComponent] { Self.parse(path: rawPath, omittingEmptyComponents: false).components } - // Second, find the node to start the search relative to. - // This may consume or or more path components. See implementation for details. - var remaining = path[...] - var node = try findRoot(parentID: parent, parsedPath: parsedPathForError, remaining: &remaining, isAbsolute: isAbsolute, onlyFindSymbols: onlyFindSymbols) + if !onlyFindSymbols { + // If non-symbol matches are possible there is a fixed order to try resolving the link: + // Articles match before tutorials which match before the tutorial overview page which match before symbols. + + // Non-symbols have a very shallow hierarchy so the simplified search peak at the first few layers and then searches only one subtree once if finds a probable match. + lookForArticleRoot: if !isKnownTutorialPath { + if articlesContainer.matches(firstComponent) { + if let next = remaining.dropFirst().first { + if !articlesContainer.anyChildMatches(next) { + break lookForArticleRoot + } + } + return try searchForNode(descendingFrom: articlesContainer, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError) + } else if articlesContainer.anyChildMatches(firstComponent) { + return try searchForNode(descendingFrom: articlesContainer, pathComponents: remaining, parsedPathForError: parsedPathForError) + } + } + if !isKnownDocumentationPath { + if tutorialContainer.matches(firstComponent) { + return try searchForNode(descendingFrom: tutorialContainer, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError) + } else if tutorialContainer.anyChildMatches(firstComponent) { + return try searchForNode(descendingFrom: tutorialContainer, pathComponents: remaining, parsedPathForError: parsedPathForError) + } + // The parent for tutorial overviews / technologies is "tutorials" which has already been removed above, so no need to check against that name. + else if tutorialOverviewContainer.anyChildMatches(firstComponent) { + return try searchForNode(descendingFrom: tutorialOverviewContainer, pathComponents: remaining, parsedPathForError: parsedPathForError) + } + } + } + + // A function to avoid repeating the + func searchForNodeInModules() throws -> Node { + // Note: This captures `parentID`, `remaining`, and `parsedPathForError`. + if let moduleMatch = modules[firstComponent.full] ?? modules[firstComponent.name] { + return try searchForNode(descendingFrom: moduleMatch, pathComponents: remaining.dropFirst(), parsedPathForError: parsedPathForError) + } + if modules.count == 1 { + do { + return try searchForNode(descendingFrom: modules.first!.value, pathComponents: remaining, parsedPathForError: parsedPathForError) + } catch { + // Ignore this error and raise an error about not finding the module instead. + } + } + let topLevelNames = Set(modules.keys + [articlesContainer.name, tutorialContainer.name]) + throw Error.notFound(remaining: Array(remaining), availableChildren: topLevelNames) + } + + // A recursive function to traverse up the path hierarchy searching for the matching node + func searchForNodeUpTheHierarchy(from startingPoint: Node?, path: ArraySlice) throws -> Node { + guard let possibleStartingPoint = startingPoint else { + // If the search has reached the top of the hierarchy, check the modules as a base case to break the recursion. + do { + return try searchForNodeInModules() + } catch { + // If the node couldn't be found in the modules, search the non-matching parent to achieve a more specific error message + if let parentID = parentID { + return try searchForNode(descendingFrom: lookup[parentID]!, pathComponents: path, parsedPathForError: parsedPathForError) + } + throw error + } + } + + // If the path isn't empty we would have already found a node. + let firstComponent = path.first! + + // Keep track of the inner most error and raise that if no node is found. + var innerMostError: Swift.Error? + + // If the starting point's children match this component, descend the path hierarchy from there. + if possibleStartingPoint.anyChildMatches(firstComponent) { + do { + return try searchForNode(descendingFrom: possibleStartingPoint, pathComponents: path, parsedPathForError: parsedPathForError) + } catch { + innerMostError = error + } + } + // It's possible that the component is ambiguous at the parent. Checking if this node matches the first component avoids that ambiguity. + if possibleStartingPoint.matches(firstComponent) { + do { + return try searchForNode(descendingFrom: possibleStartingPoint, pathComponents: path.dropFirst(), parsedPathForError: parsedPathForError) + } catch { + if innerMostError == nil { + innerMostError = error + } + } + } + + do { + return try searchForNodeUpTheHierarchy(from: possibleStartingPoint.parent, path: path) + } catch { + throw innerMostError ?? error + } + } + + if !isAbsolute, let parentID = parentID { + // If this is a relative link with a known starting point, search from that node up the hierarchy. + return try searchForNodeUpTheHierarchy(from: lookup[parentID]!, path: remaining) + } + return try searchForNodeInModules() + } + + private func searchForNode( + descendingFrom startingPoint: Node, + pathComponents: ArraySlice, + parsedPathForError: () -> [PathComponent] + ) throws -> Node { + var node = startingPoint + var remaining = pathComponents[...] // Third, search for the match relative to the start node. if remaining.isEmpty { @@ -345,12 +466,12 @@ struct PathHierarchy { // Search for the remaining components from the node while true { - let (children, pathComponent) = try findChildTree(node: &node, parsedPath: parsedPathForError, remaining: remaining) + let (children, pathComponent) = try findChildTree(node: &node, parsedPath: parsedPathForError(), remaining: remaining) do { guard let child = try children.find(pathComponent.kind, pathComponent.hash) else { // The search has ended with a node that doesn't have a child matching the next path component. - throw makePartialResultError(node: node, parsedPath: parsedPathForError, remaining: remaining) + throw makePartialResultError(node: node, parsedPath: parsedPathForError(), remaining: remaining) } node = child remaining = remaining.dropFirst() @@ -360,7 +481,7 @@ struct PathHierarchy { } } catch DisambiguationTree.Error.lookupCollision(let collisions) { func handleWrappedCollision() throws -> Node { - try handleCollision(node: node, parsedPath: parsedPathForError, remaining: remaining, collisions: collisions) + try handleCollision(node: node, parsedPath: parsedPathForError(), remaining: remaining, collisions: collisions) } // See if the collision can be resolved by looking ahead on level deeper. @@ -402,7 +523,7 @@ struct PathHierarchy { return possibleMatches.first(where: { $0.symbol?.identifier.interfaceLanguage == "swift" }) ?? possibleMatches.first! } // Couldn't resolve the collision by look ahead. - return try handleCollision(node: node, parsedPath: parsedPathForError, remaining: remaining, collisions: collisions) + return try handleCollision(node: node, parsedPath: parsedPathForError(), remaining: remaining, collisions: collisions) } } } @@ -446,7 +567,6 @@ struct PathHierarchy { ) } - return Error.unknownName( partialResult: ( node, @@ -472,139 +592,10 @@ struct PathHierarchy { return (match, pathComponent) } else if let match = node.children[pathComponent.name] { return (match, pathComponent) - } else { - if node.name == pathComponent.name || node.name == pathComponent.full, let parent = node.parent { - // When multiple path components in a row have the same name it's possible that the search started at a node that's - // too deep in the hierarchy that won't find the final result. - // Check if a match would be found in the parent before raising an error. - if let match = parent.children[pathComponent.name] { - node = parent - return (match, pathComponent) - } else if let match = parent.children[pathComponent.full] { - node = parent - // The path component parsing may treat dash separated words as disambiguation information. - // If the parsed name didn't match, also try the original. - pathComponent.kind = nil - pathComponent.hash = nil - return (match, pathComponent) - } - } } // The search has ended with a node that doesn't have a child matching the next path component. throw makePartialResultError(node: node, parsedPath: parsedPath(), remaining: remaining) } - - /// Attempt to find the node to start the relative search relative to. - /// - /// - Parameters: - /// - parentID: An optional ID of the node to start the search relative to. - /// - remaining: The parsed path components. - /// - isAbsolute: If the parsed path represent an absolute documentation link. - /// - onlyFindSymbols: If symbol results are required. - /// - Returns: The node to start the relative search relative to. - private func findRoot(parentID: ResolvedIdentifier?, parsedPath: @autoclosure () -> [PathComponent], remaining: inout ArraySlice, isAbsolute: Bool, onlyFindSymbols: Bool) throws -> Node { - // If the first path component is "tutorials" or "documentation" then that - let isKnownTutorialPath = remaining.first!.full == NodeURLGenerator.Path.tutorialsFolderName - let isKnownDocumentationPath = remaining.first!.full == NodeURLGenerator.Path.documentationFolderName - if isKnownDocumentationPath || isKnownTutorialPath { - // Drop that component since it isn't represented in the path hierarchy. - remaining.removeFirst() - } - guard let component = remaining.first else { - throw Error.notFound(remaining: [], availableChildren: []) - } - - if !onlyFindSymbols { - // If non-symbol matches are possible there is a fixed order to try resolving the link: - // Articles match before tutorials which match before the tutorial overview page which match before symbols. - lookForArticleRoot: if !isKnownTutorialPath { - if articlesContainer.name == component.name || articlesContainer.name == component.full { - if let next = remaining.dropFirst().first { - if !articlesContainer.children.keys.contains(next.name) && !articlesContainer.children.keys.contains(next.full) { - break lookForArticleRoot - } - } - remaining = remaining.dropFirst() - return articlesContainer - } else if articlesContainer.children.keys.contains(component.name) || articlesContainer.children.keys.contains(component.full) { - return articlesContainer - } - } - if !isKnownDocumentationPath { - if tutorialContainer.name == component.name || tutorialContainer.name == component.full { - remaining = remaining.dropFirst() - return tutorialContainer - } else if tutorialContainer.children.keys.contains(component.name) || tutorialContainer.children.keys.contains(component.full) { - return tutorialContainer - } - // The parent for tutorial overviews / technologies is "tutorials" which has already been removed above, so no need to check against that name. - else if tutorialOverviewContainer.children.keys.contains(component.name) || tutorialOverviewContainer.children.keys.contains(component.full) { - return tutorialOverviewContainer - } - } - if !isKnownTutorialPath && isAbsolute { - // If this is an absolute non-tutorial link, then the first component will be a module name. - if let matched = modules[component.name] ?? modules[component.full] { - remaining = remaining.dropFirst() - return matched - } - } - } - - func matches(node: Node, component: PathComponent) -> Bool { - if let symbol = node.symbol { - return node.name == component.name - && (component.kind == nil || component.kind == symbol.kind.identifier.identifier) - && (component.hash == nil || component.hash == symbol.identifier.precise.stableHashString) - } else { - return node.name == component.full - } - } - - if let parentID = parentID { - // If a parent ID was provided, start at that node and continue up the hierarchy until that node has a child that matches the first path components name. - var parentNode = lookup[parentID]! - let firstComponent = remaining.first! - // Check if the start node has a child that matches the first path components name. - if parentNode.children.keys.contains(firstComponent.name) || parentNode.children.keys.contains(firstComponent.full) { - return parentNode - } - // Check if the start node itself matches the first path components name. - if matches(node: parentNode, component: firstComponent) { - remaining = remaining.dropFirst() - return parentNode - } - while !parentNode.children.keys.contains(firstComponent.name) && !parentNode.children.keys.contains(firstComponent.full) { - guard let parent = parentNode.parent else { - if matches(node: parentNode, component: firstComponent){ - remaining = remaining.dropFirst() - return parentNode - } - if let matched = modules[component.name] ?? modules[component.full] { - remaining = remaining.dropFirst() - return matched - } - // No node up the hierarchy from the provided parent has a child that matches the first path component. - // Go back to the provided parent node for diagnostic information about its available children. - parentNode = lookup[parentID]! - throw makePartialResultError(node: parentNode, parsedPath: parsedPath(), remaining: remaining) - } - parentNode = parent - } - return parentNode - } - - // If no parent ID was provided, check if the first path component is a module name. - if let matched = modules[component.name] ?? modules[component.full] { - remaining = remaining.dropFirst() - return matched - } - - // No place to start the search from could be found. - // It would be a nice future improvement to allow skipping the module and find top level symbols directly. - let topLevelNames = Set(modules.keys + [articlesContainer.name, tutorialContainer.name]) - throw Error.notFound(remaining: Array(remaining), availableChildren: topLevelNames) - } } extension PathHierarchy { @@ -681,6 +672,24 @@ extension PathHierarchy { } } } + +private extension PathHierarchy.Node { + func matches(_ component: PathHierarchy.PathComponent) -> Bool { + if let symbol = symbol { + return name == component.name + && (component.kind == nil || component.kind == symbol.kind.identifier.identifier) + && (component.hash == nil || component.hash == symbol.identifier.precise.stableHashString) + } else { + return name == component.full + } + } + + func anyChildMatches(_ component: PathHierarchy.PathComponent) -> Bool { + let keys = children.keys + return keys.contains(component.name) || keys.contains(component.full) + } +} + // MARK: Parsing documentation links /// All known symbol kind identifiers. @@ -1456,3 +1465,4 @@ private extension SourceRange { return .makeRelativeRange(startColumn: startColumn, endColumn: startColumn + length) } } + diff --git a/Sources/SwiftDocC/Model/Rendering/Symbol/ConformanceSection.swift b/Sources/SwiftDocC/Model/Rendering/Symbol/ConformanceSection.swift index 7ddca0bb1a..173091eaba 100644 --- a/Sources/SwiftDocC/Model/Rendering/Symbol/ConformanceSection.swift +++ b/Sources/SwiftDocC/Model/Rendering/Symbol/ConformanceSection.swift @@ -87,10 +87,16 @@ public struct ConformanceSection: Codable, Equatable { } // Adds "," or ", and" to the requirements wherever necessary. - let merged = zip(rendered, separators).flatMap({ $0 + [$1] }) - + rendered[separators.count...].flatMap({ $0 }) + var merged: [RenderInlineContent] = [] + merged.reserveCapacity(rendered.count * 4) // 3 for each constraint and 1 for each separator + for (constraint, separator) in zip(rendered, separators) { + merged.append(contentsOf: constraint) + merged.append(separator) + } + merged.append(contentsOf: rendered.last!) + merged.append(.text(".")) - self.constraints = merged + [RenderInlineContent.text(".")] + self.constraints = merged } private static let selfPrefix = "Self." diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index eef53488ab..e628d8bcfd 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -3040,6 +3040,83 @@ let expected = """ } } + func testMatchesDocumentationExtensionsRelativeToModule() throws { + try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) + + let (_, bundle, context) = try testBundleAndContext(copying: "MixedLanguageFrameworkWithLanguageRefinements") { url in + // Top level symbols, omitting the module name + try """ + # ``MyStruct/myStructProperty`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + my struct property + """.write(to: url.appendingPathComponent("struct-property.md"), atomically: true, encoding: .utf8) + + try """ + # ``MyTypeAlias`` + + @Metadata { + @DocumentationExtension(mergeBehavior: override) + } + + my type alias + """.write(to: url.appendingPathComponent("alias.md"), atomically: true, encoding: .utf8) + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyStruct/myStructProperty", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "my struct property", "The abstract should be from the overriding documentation extension.") + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/MixedFramework/MyTypeAlias", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "my type alias", "The abstract should be from the overriding documentation extension.") + } + } + + func testCurationOfSymbolsWithSameNameAsModule() throws { + try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) + + let (_, bundle, context) = try testBundleAndContext(copying: "SymbolsWithSameNameAsModule") { url in + // Top level symbols, omitting the module name + try """ + # ``Something`` + + This documentation extension covers the module symbol + + ## Topics + + This link curates the top-level struct + + - ``Something`` + """.write(to: url.appendingPathComponent("something.md"), atomically: true, encoding: .utf8) + } + + do { + // The resolved reference needs more disambiguation than the documentation extension link did. + let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/Something", sourceLanguage: .swift) + + let node = try context.entity(with: reference) + let symbol = try XCTUnwrap(node.semantic as? Symbol) + XCTAssertEqual(symbol.abstract?.plainText, "This documentation extension covers the module symbol", "The abstract should be from the overriding documentation extension.") + + let topics = try XCTUnwrap(symbol.topics?.taskGroups.first) + XCTAssertEqual(topics.abstract?.paragraph.plainText, "This link curates the top-level struct") + XCTAssertEqual(topics.links.first?.destination, "doc://SymbolsWithSameNameAsModule/documentation/Something/Something") + } + } + func testMultipleDocumentationExtensionMatchDiagnostic() throws { try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift index 2ab5b26dd2..f3160c47b7 100644 --- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift @@ -1149,6 +1149,54 @@ class PathHierarchyTests: XCTestCase { "/ShapeKit/OverloadedEnum/firstTestMemberName(_:)-88rbf") } + func testSymbolsWithSameNameAsModule() throws { + try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) + let (_, context) = try testBundleAndContext(named: "SymbolsWithSameNameAsModule") + let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy) + + // /* in a module named "Something "*/ + // public struct Something { + // public enum Something { + // case first + // } + // public var second = 0 + // } + // public struct Wrapper { + // public struct Something { + // public var third = 0 + // } + // } + try assertFindsPath("Something", in: tree, asSymbolID: "Something") + try assertFindsPath("/Something", in: tree, asSymbolID: "Something") + + let moduleID = try tree.find(path: "/Something", onlyFindSymbols: true) + XCTAssertEqual(try tree.findSymbol(path: "/Something", parent: moduleID).identifier.precise, "Something") + XCTAssertEqual(try tree.findSymbol(path: "Something-module", parent: moduleID).identifier.precise, "Something") + XCTAssertEqual(try tree.findSymbol(path: "Something", parent: moduleID).identifier.precise, "s:9SomethingAAV") + XCTAssertEqual(try tree.findSymbol(path: "/Something/Something", parent: moduleID).identifier.precise, "s:9SomethingAAV") + XCTAssertEqual(try tree.findSymbol(path: "Something/Something", parent: moduleID).identifier.precise, "s:9SomethingAAVAAO") + XCTAssertEqual(try tree.findSymbol(path: "Something/Something/Something", parent: moduleID).identifier.precise, "s:9SomethingAAVAAO") + XCTAssertEqual(try tree.findSymbol(path: "/Something/Something/Something", parent: moduleID).identifier.precise, "s:9SomethingAAVAAO") + XCTAssertEqual(try tree.findSymbol(path: "/Something/Something", parent: moduleID).identifier.precise, "s:9SomethingAAV") + XCTAssertEqual(try tree.findSymbol(path: "Something/second", parent: moduleID).identifier.precise, "s:9SomethingAAV6secondSivp") + + let topLevelSymbolID = try tree.find(path: "/Something/Something", onlyFindSymbols: true) + XCTAssertEqual(try tree.findSymbol(path: "Something", parent: topLevelSymbolID).identifier.precise, "s:9SomethingAAVAAO") + XCTAssertEqual(try tree.findSymbol(path: "Something/Something", parent: topLevelSymbolID).identifier.precise, "s:9SomethingAAVAAO") + XCTAssertEqual(try tree.findSymbol(path: "Something/second", parent: topLevelSymbolID).identifier.precise, "s:9SomethingAAV6secondSivp") + + let wrapperID = try tree.find(path: "/Something/Wrapper", onlyFindSymbols: true) + XCTAssertEqual(try tree.findSymbol(path: "Something/second", parent: wrapperID).identifier.precise, "s:9SomethingAAV6secondSivp") + XCTAssertEqual(try tree.findSymbol(path: "Something/third", parent: wrapperID).identifier.precise, "s:9Something7WrapperVAAV5thirdSivp") + + let wrappedID = try tree.find(path: "/Something/Wrapper/Something", onlyFindSymbols: true) + XCTAssertEqual(try tree.findSymbol(path: "Something/second", parent: wrappedID).identifier.precise, "s:9SomethingAAV6secondSivp") + XCTAssertEqual(try tree.findSymbol(path: "Something/third", parent: wrappedID).identifier.precise, "s:9Something7WrapperVAAV5thirdSivp") + + XCTAssertEqual(try tree.findSymbol(path: "Something/first", parent: topLevelSymbolID).identifier.precise, "s:9SomethingAAVAAO5firstyA2CmF") + XCTAssertEqual(try tree.findSymbol(path: "Something/second", parent: topLevelSymbolID).identifier.precise, "s:9SomethingAAV6secondSivp") + } + func testSnippets() throws { try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver) let (_, context) = try testBundleAndContext(named: "Snippets") diff --git a/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift index acc27754c2..ae7cbf750c 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift @@ -471,21 +471,17 @@ class ReferenceResolverTests: XCTestCase { try """ # ``ModuleWithSingleExtension`` - This is a test module with an extension to ``Swift/Array#Array``. + This is a test module with an extension to ``Swift/Array``. """.write(to: topLevelArticle, atomically: true, encoding: .utf8) } // Make sure that linking to `Swift/Array` raises a diagnostic about the page having been removed - if LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver { - let diagnostic = try XCTUnwrap(context.problems.first(where: { $0.diagnostic.identifier == "org.swift.docc.removedExtensionLinkDestination" })) - XCTAssertEqual(diagnostic.possibleSolutions.count, 1) - let solution = try XCTUnwrap(diagnostic.possibleSolutions.first) - XCTAssertEqual(solution.replacements.count, 1) - let replacement = try XCTUnwrap(solution.replacements.first) - XCTAssertEqual(replacement.replacement, "`Swift/Array`") - } else { - XCTAssert(context.problems.contains(where: { $0.diagnostic.identifier == "org.swift.docc.unresolvedTopicReference" })) - } + let diagnostic = try XCTUnwrap(context.problems.first(where: { $0.diagnostic.identifier == "org.swift.docc.removedExtensionLinkDestination" })) + XCTAssertEqual(diagnostic.possibleSolutions.count, 1) + let solution = try XCTUnwrap(diagnostic.possibleSolutions.first) + XCTAssertEqual(solution.replacements.count, 1) + let replacement = try XCTUnwrap(solution.replacements.first) + XCTAssertEqual(replacement.replacement, "`Swift/Array`") // Also make sure that the extension pages are still gone let extendedModule = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/ModuleWithSingleExtension/Swift", sourceLanguage: .swift) diff --git a/Tests/SwiftDocCTests/Test Bundles/SymbolsWithSameNameAsModule.docc/Something.symbols.json b/Tests/SwiftDocCTests/Test Bundles/SymbolsWithSameNameAsModule.docc/Something.symbols.json new file mode 100644 index 0000000000..11e30cf95e --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/SymbolsWithSameNameAsModule.docc/Something.symbols.json @@ -0,0 +1,729 @@ +{ + "metadata": { + "formatVersion": { + "major": 0, + "minor": 6, + "patch": 0 + }, + "generator": "Apple Swift version 5.9 (swiftlang-5.9.0.100.22 clang-1500.0.7.24.100)" + }, + "module": { + "name": "Something", + "platform": { + "architecture": "arm64", + "vendor": "apple", + "operatingSystem": { + "name": "macosx", + "minimumVersion": { + "major": 14, + "minor": 0 + } + } + } + }, + "symbols": [ + { + "kind": { + "identifier": "swift.enum.case", + "displayName": "Case" + }, + "identifier": { + "precise": "s:9SomethingAAVAAO5firstyA2CmF", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Something", + "Something", + "first" + ], + "names": { + "title": "Something.Something.first", + "subHeading": [ + { + "kind": "keyword", + "spelling": "case" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "first" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "case" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "first" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 15, + "character": 13 + } + } + }, + { + "kind": { + "identifier": "swift.struct", + "displayName": "Structure" + }, + "identifier": { + "precise": "s:9Something7WrapperV", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Wrapper" + ], + "names": { + "title": "Wrapper", + "navigator": [ + { + "kind": "identifier", + "spelling": "Wrapper" + } + ], + "subHeading": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Wrapper" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Wrapper" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 21, + "character": 14 + } + } + }, + { + "kind": { + "identifier": "swift.property", + "displayName": "Instance Property" + }, + "identifier": { + "precise": "s:9Something7WrapperVAAV5thirdSivp", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Wrapper", + "Something", + "third" + ], + "names": { + "title": "third", + "subHeading": [ + { + "kind": "keyword", + "spelling": "var" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "third" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Int", + "preciseIdentifier": "s:Si" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "var" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "third" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Int", + "preciseIdentifier": "s:Si" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 24, + "character": 19 + } + } + }, + { + "kind": { + "identifier": "swift.func.op", + "displayName": "Operator" + }, + "identifier": { + "precise": "s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:9SomethingAAVAAO", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Something", + "Something", + "!=(_:_:)" + ], + "names": { + "title": "!=(_:_:)", + "subHeading": [ + { + "kind": "keyword", + "spelling": "static" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "keyword", + "spelling": "func" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "!=" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "text", + "spelling": "(" + }, + { + "kind": "typeIdentifier", + "spelling": "Self" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "Self" + }, + { + "kind": "text", + "spelling": ") -> " + }, + { + "kind": "typeIdentifier", + "spelling": "Bool", + "preciseIdentifier": "s:Sb" + } + ] + }, + "functionSignature": { + "parameters": [ + { + "name": "lhs", + "declarationFragments": [ + { + "kind": "identifier", + "spelling": "lhs" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Self" + } + ] + }, + { + "name": "rhs", + "declarationFragments": [ + { + "kind": "identifier", + "spelling": "rhs" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Self" + } + ] + } + ], + "returns": [ + { + "kind": "typeIdentifier", + "spelling": "Bool", + "preciseIdentifier": "s:Sb" + } + ] + }, + "swiftExtension": { + "extendedModule": "Swift", + "typeKind": "swift.protocol" + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "static" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "keyword", + "spelling": "func" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "!=" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "text", + "spelling": "(" + }, + { + "kind": "internalParam", + "spelling": "lhs" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Self" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "internalParam", + "spelling": "rhs" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Self" + }, + { + "kind": "text", + "spelling": ") -> " + }, + { + "kind": "typeIdentifier", + "spelling": "Bool", + "preciseIdentifier": "s:Sb" + } + ], + "accessLevel": "public" + }, + { + "kind": { + "identifier": "swift.property", + "displayName": "Instance Property" + }, + "identifier": { + "precise": "s:9SomethingAAV6secondSivp", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Something", + "second" + ], + "names": { + "title": "second", + "subHeading": [ + { + "kind": "keyword", + "spelling": "var" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "second" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Int", + "preciseIdentifier": "s:Si" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "var" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "second" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Int", + "preciseIdentifier": "s:Si" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 17, + "character": 15 + } + } + }, + { + "kind": { + "identifier": "swift.struct", + "displayName": "Structure" + }, + "identifier": { + "precise": "s:9SomethingAAV", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Something" + ], + "names": { + "title": "Something", + "navigator": [ + { + "kind": "identifier", + "spelling": "Something" + } + ], + "subHeading": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Something" + } + ] + }, + "docComment": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "module": "Something", + "lines": [ + { + "range": { + "start": { + "line": 11, + "character": 4 + }, + "end": { + "line": 11, + "character": 49 + } + }, + "text": "A top-level type names the same as the module" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Something" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 12, + "character": 14 + } + } + }, + { + "kind": { + "identifier": "swift.struct", + "displayName": "Structure" + }, + "identifier": { + "precise": "s:9Something7WrapperVAAV", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Wrapper", + "Something" + ], + "names": { + "title": "Wrapper.Something", + "navigator": [ + { + "kind": "identifier", + "spelling": "Something" + } + ], + "subHeading": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Something" + } + ] + }, + "docComment": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "module": "Something", + "lines": [ + { + "range": { + "start": { + "line": 22, + "character": 8 + }, + "end": { + "line": 22, + "character": 84 + } + }, + "text": "An inner type with the same name as the module and as other top-level types." + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "struct" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Something" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 23, + "character": 18 + } + } + }, + { + "kind": { + "identifier": "swift.enum", + "displayName": "Enumeration" + }, + "identifier": { + "precise": "s:9SomethingAAVAAO", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Something", + "Something" + ], + "names": { + "title": "Something.Something", + "navigator": [ + { + "kind": "identifier", + "spelling": "Something" + } + ], + "subHeading": [ + { + "kind": "keyword", + "spelling": "enum" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Something" + } + ] + }, + "declarationFragments": [ + { + "kind": "keyword", + "spelling": "enum" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Something" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/username/path/to/Something/TypeNameCollisions.swift", + "position": { + "line": 14, + "character": 16 + } + } + } + ], + "relationships": [ + { + "kind": "memberOf", + "source": "s:9Something7WrapperVAAV", + "target": "s:9Something7WrapperV" + }, + { + "kind": "conformsTo", + "source": "s:9SomethingAAVAAO", + "target": "s:SH", + "targetFallback": "Swift.Hashable" + }, + { + "kind": "memberOf", + "source": "s:9SomethingAAVAAO5firstyA2CmF", + "target": "s:9SomethingAAVAAO" + }, + { + "kind": "memberOf", + "source": "s:9SomethingAAVAAO", + "target": "s:9SomethingAAV" + }, + { + "kind": "conformsTo", + "source": "s:9SomethingAAVAAO", + "target": "s:SQ", + "targetFallback": "Swift.Equatable" + }, + { + "kind": "memberOf", + "source": "s:9SomethingAAV6secondSivp", + "target": "s:9SomethingAAV" + }, + { + "kind": "memberOf", + "source": "s:9Something7WrapperVAAV5thirdSivp", + "target": "s:9Something7WrapperVAAV" + }, + { + "kind": "memberOf", + "source": "s:SQsE2neoiySbx_xtFZ::SYNTHESIZED::s:9SomethingAAVAAO", + "target": "s:9SomethingAAVAAO", + "sourceOrigin": { + "identifier": "s:SQsE2neoiySbx_xtFZ", + "displayName": "Equatable.!=(_:_:)" + } + } + ] +} diff --git a/Tests/SwiftDocCUtilitiesTests/JSONEncodingRenderNodeWriterTests.swift b/Tests/SwiftDocCUtilitiesTests/JSONEncodingRenderNodeWriterTests.swift index c34bebb910..6f32100d91 100644 --- a/Tests/SwiftDocCUtilitiesTests/JSONEncodingRenderNodeWriterTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/JSONEncodingRenderNodeWriterTests.swift @@ -36,13 +36,17 @@ class JSONEncodingRenderNodeWriterTests: XCTestCase { // We take precautions in case we deadlock to stop the execution with a failing code. // In case the original issue is present and we deadlock, we fatalError from a bg thread. - var didReleaseExecution = false - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 2.0) { - guard didReleaseExecution else { - fatalError("\(#file):\(#function) failed to release the execution.") + let didReleaseExecution = expectation(description: "Did release execution") + + DispatchQueue.global(qos: .default).async { + do { + try writer.write(renderNode) + XCTFail("Did not throw when writing to invalid path.") + } catch { + didReleaseExecution.fulfill() } } - XCTAssertThrowsError(try writer.write(renderNode), "Did not throw when writing to invalid path.") { _ in } - didReleaseExecution = true + + waitForExpectations(timeout: 2.0) } }