diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift index cc8821034..d54753bd4 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift @@ -44,13 +44,17 @@ struct DeclarationsSectionTranslator: RenderSectionTranslator { /// Fetch the common fragments for the given references, or compute it if necessary. func commonFragments( for mainDeclaration: OverloadDeclaration, - overloadDeclarations: [OverloadDeclaration] + overloadDeclarations: [OverloadDeclaration], + mainDeclarationIndex: Int ) -> [SymbolGraph.Symbol.DeclarationFragments.Fragment] { if let fragments = commonFragments(for: mainDeclaration.reference) { return fragments } - let preProcessedDeclarations = [mainDeclaration.declaration] + overloadDeclarations.map(\.declaration) + var preProcessedDeclarations = overloadDeclarations.map(\.declaration) + // Insert the main declaration according to the display index so the ordering is consistent + // between overloaded symbols + preProcessedDeclarations.insert(mainDeclaration.declaration, at: mainDeclarationIndex) // Collect the "common fragments" so we can highlight the ones that are different // in each declaration @@ -216,7 +220,9 @@ struct DeclarationsSectionTranslator: RenderSectionTranslator { // in each declaration let commonFragments = commonFragments( for: (mainDeclaration, renderNode.identifier, nil), - overloadDeclarations: processedOverloadDeclarations) + overloadDeclarations: processedOverloadDeclarations, + mainDeclarationIndex: overloads.displayIndex + ) renderedTokens = translateDeclaration( mainDeclaration, diff --git a/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift b/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift index b7a887235..8208f894f 100644 --- a/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift +++ b/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift @@ -12,6 +12,7 @@ import Foundation import XCTest @testable import SwiftDocC import SwiftDocCTestUtilities +import SymbolKit class DeclarationsRenderSectionTests: XCTestCase { func testDecodingTokens() throws { @@ -322,6 +323,91 @@ class DeclarationsRenderSectionTests: XCTestCase { } } + func testInconsistentHighlightDiff() throws { + enableFeatureFlag(\.isExperimentalOverloadedSymbolPresentationEnabled) + + // Generate a symbol graph with many overload groups that share declarations. + // The overloaded declarations have two legitimate solutions for their longest common subsequence: + // one that ends in a close-parenthesis, and one that ends in a space. + // By alternating the order in which these declarations appear, + // the computed difference highlighting can differ + // unless the declarations are sorted prior to the calculation. + // Ensure that the overload difference highlighting is consistent for these declarations. + + // init(_ content: MyClass) throws + let declaration1: SymbolGraph.Symbol.DeclarationFragments = .init(declarationFragments: [ + .init(kind: .keyword, spelling: "init", preciseIdentifier: nil), + .init(kind: .text, spelling: "(", preciseIdentifier: nil), + .init(kind: .externalParameter, spelling: "_", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "content", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "MyClass", preciseIdentifier: "s:MyClass"), + .init(kind: .text, spelling: ") ", preciseIdentifier: nil), + .init(kind: .keyword, spelling: "throws", preciseIdentifier: nil), + ]) + + // init(_ content: some ConvertibleToMyClass) + let declaration2: SymbolGraph.Symbol.DeclarationFragments = .init(declarationFragments: [ + .init(kind: .keyword, spelling: "init", preciseIdentifier: nil), + .init(kind: .text, spelling: "(", preciseIdentifier: nil), + .init(kind: .externalParameter, spelling: "_", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .internalParameter, spelling: "content", preciseIdentifier: nil), + .init(kind: .text, spelling: ": ", preciseIdentifier: nil), + .init(kind: .keyword, spelling: "some", preciseIdentifier: nil), + .init(kind: .text, spelling: " ", preciseIdentifier: nil), + .init(kind: .typeIdentifier, spelling: "ConvertibleToMyClass", preciseIdentifier: "s:ConvertibleToMyClass"), + .init(kind: .text, spelling: ")", preciseIdentifier: nil), + ]) + let overloadsCount = 10 + let symbols = (0...overloadsCount).flatMap({ index in + let reverseDeclarations = index % 2 != 0 + return [ + makeSymbol( + id: "overload-\(index)-1", + kind: .func, + pathComponents: ["overload-\(index)"], + otherMixins: [reverseDeclarations ? declaration2 : declaration1]), + makeSymbol( + id: "overload-\(index)-2", + kind: .func, + pathComponents: ["overload-\(index)"], + otherMixins: [reverseDeclarations ? declaration1 : declaration2]), + ] + }) + let symbolGraph = makeSymbolGraph(moduleName: "FancierOverloads", symbols: symbols) + + let catalog = Folder(name: "unit-test.docc", content: [ + InfoPlist(displayName: "FancierOverloads", identifier: "com.test.example"), + JSONFile(name: "FancierOverloads.symbols.json", content: symbolGraph), + ]) + + let (bundle, context) = try loadBundle(catalog: catalog) + + func assertDeclarations(for USR: String, file: StaticString = #filePath, line: UInt = #line) throws { + let reference = try XCTUnwrap(context.documentationCache.reference(symbolID: USR), file: file, line: line) + let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Symbol, file: file, line: line) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: reference) + let renderNode = try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode, file: file, line: line) + let declarationsSection = try XCTUnwrap(renderNode.primaryContentSections.compactMap({ $0 as? DeclarationsRenderSection }).first, file: file, line: line) + XCTAssertEqual(declarationsSection.declarations.count, 1, file: file, line: line) + let declarations = try XCTUnwrap(declarationsSection.declarations.first, file: file, line: line) + + XCTAssertEqual(declarationsAndHighlights(for: declarations), [ + "init(_ content: MyClass) throws", + " ~~~~~~~~ ~~~~~~", + "init(_ content: some ConvertibleToMyClass)", + " ~~~~ ~~~~~~~~~~~~~~~~~~~~~", + ], file: file, line: line) + } + + for i in 0...overloadsCount { + try assertDeclarations(for: "overload-\(i)-1") + try assertDeclarations(for: "overload-\(i)-2") + } + } + func testDontHighlightWhenOverloadsAreDisabled() throws { let symbolGraphFile = Bundle.module.url( forResource: "FancyOverloads", @@ -403,3 +489,12 @@ func declarationAndHighlights(for tokens: [DeclarationRenderSection.Token]) -> [ tokens.map({ String(repeating: $0.highlight == .changed ? "~" : " ", count: $0.text.count) }).joined() ] } + +func declarationsAndHighlights(for section: DeclarationRenderSection) -> [String] { + guard let otherDeclarations = section.otherDeclarations else { + return [] + } + var declarations = otherDeclarations.declarations.map(\.tokens) + declarations.insert(section.tokens, at: otherDeclarations.displayIndex) + return declarations.flatMap(declarationAndHighlights(for:)) +}