diff --git a/Package.resolved b/Package.resolved index 6d240fd43d..4a65166522 100644 --- a/Package.resolved +++ b/Package.resolved @@ -63,6 +63,15 @@ "revision" : "4c245d4b7264fbabb0fa1f7b3411c2c5bce4e2d9" } }, + { + "identity" : "swift-format", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-format", + "state" : { + "revision" : "93ebb779c07dad2598919de8202d6df1f97189d4", + "version" : "601.0.0-prerelease-2024-10-01" + } + }, { "identity" : "swift-lmdb", "kind" : "remoteSourceControl", @@ -90,6 +99,15 @@ "version" : "2.68.0" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f4acb89d4a542c3ba54cadcf17f01c857dda309c", + "version" : "601.0.0-prerelease-2024-09-30" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3071b8166e..23f722041e 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "SwiftDocC", platforms: [ - .macOS(.v10_15), + .macOS(.v12), // TODO: see if this can be configured back to 10.15 .iOS(.v13) ], products: [ @@ -45,6 +45,8 @@ let package = Package( .product(name: "SymbolKit", package: "swift-docc-symbolkit"), .product(name: "CLMDB", package: "swift-lmdb"), .product(name: "Crypto", package: "swift-crypto"), + .product(name: "SwiftFormat", package: "swift-format"), + .product(name: "SwiftSyntax", package: "swift-syntax"), ], swiftSettings: swiftSettings ), @@ -140,6 +142,8 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { .package(url: "https://github.com/apple/swift-docc-symbolkit", branch: "main"), .package(url: "https://github.com/apple/swift-crypto.git", from: "2.5.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.2.0"), + .package(url: "https://github.com/swiftlang/swift-format", from: "601.0.0-prerelease-2024-10-01"), + .package(url: "https://github.com/swiftlang/swift-syntax", from: "601.0.0-prerelease-2024-09-30"), ] } else { // Building in the Swift.org CI system, so rely on local versions of dependencies. diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator+Formatting.swift b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator+Formatting.swift new file mode 100644 index 0000000000..4a47d2fe49 --- /dev/null +++ b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator+Formatting.swift @@ -0,0 +1,92 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import SymbolKit + +// All logic related to taking a list of fragments from a symbolgraph symbol +// and turning it into a similar list with additional whitespace formatting. +// +// The basic logic is as follows: +// +// 1. Extract the text from the fragment list and use `SwiftFormat` to output +// a String of formatted source code +// (see `Utility/Formatting/SyntaxFormatter.swift`) +// 2. Given the String of formatted source code from step 1, use +// `FragmentBuilder` to turn that text back into a new list of fragments +// (see `Utility/Formatting/FragmentBuilder.swift`) +// +// ``` +// [Fragment] -> String -> [Fragment] +// ``` +extension DeclarationsSectionTranslator { + typealias DeclarationFragments = SymbolGraph.Symbol.DeclarationFragments + typealias Fragment = DeclarationFragments.Fragment + + func formatted(declarations: [[PlatformName?]:DeclarationFragments]) + -> [[PlatformName?]:DeclarationFragments] { + declarations.mapValues { formatted(declaration: $0) } + } + + func formatted(declaration: DeclarationFragments) -> DeclarationFragments { + let formattedFragments = formatted(fragments: declaration.declarationFragments) + return DeclarationFragments(declarationFragments: formattedFragments) + } + + /// Returns an array of `Fragment` elements with additional whitespace + /// formatting text, like indentation and newlines. + /// + /// - Parameter fragments: An array of `Fragment` elements for a declaration + /// provided by `SymbolKit`. + /// + /// - Returns: A new array of `Fragment` elements with the same source code + /// text as the input fragments and also including some additional text + /// for indentation and splitting the code across multiple lines. + func formatted(fragments: [Fragment]) -> [Fragment] { + do { + let ids = createIdentifierMap(fragments) + let rawText = extractText(from: fragments) + let formattedText = try format(source: rawText) + let formattedFragments = buildFragments(from: formattedText, identifiedBy: ids) + + return formattedFragments + } catch { + // if there's an error that happens when using swift-format, ignore + // it and simply return back the original, unformatted fragments + return fragments + } + } + + private func createIdentifierMap(_ fragments: [Fragment]) -> [String:String] { + var map: [String:String] = [:] + + for fragment in fragments { + if let id = fragment.preciseIdentifier { + map[fragment.spelling] = id + } + } + + return map + } + + private func extractText(from fragments: [Fragment]) -> String { + fragments.reduce("") { "\($0)\($1.spelling)" } + } + + private func format(source: String) throws -> String { + try SyntaxFormatter().format(source: source) + } + + private func buildFragments( + from source: String, + identifiedBy ids: [String:String] = [:] + ) -> [Fragment] { + FragmentBuilder().buildFragments(from: source, identifiers: ids) + } +} diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift index f679cf6c58..2de52ffbec 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift @@ -72,6 +72,15 @@ struct DeclarationsSectionTranslator: RenderSectionTranslator { return nil } + // if --enable-experimental-declaration-formatting is enabled, try + // to format the decl fragments using swift-format + // (see `DeclarationSectionTranslator+Formatting.swift`) + let declaration = FeatureFlags.current.isExperimentalDeclarationFormattingEnabled ? ( + formatted(declarations: declaration) + ) : ( + declaration + ) + /// Convert a ``SymbolGraph`` declaration fragment into a ``DeclarationRenderSection/Token`` /// by resolving any symbol USRs to the appropriate reference link. func translateFragment( diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index 8051d44015..66925a3f7c 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -35,6 +35,10 @@ public struct FeatureFlags: Codable { get { isParametersAndReturnsValidationEnabled } set { isParametersAndReturnsValidationEnabled = newValue } } + + /// Whether or not experimental support for formatting Swift symbol + /// declarations using swift-format is enabled. + public var isExperimentalDeclarationFormattingEnabled = false /// Creates a set of feature flags with the given values. /// diff --git a/Sources/SwiftDocC/Utility/Formatting/FragmentBuilder.swift b/Sources/SwiftDocC/Utility/Formatting/FragmentBuilder.swift new file mode 100644 index 0000000000..4ca8364a71 --- /dev/null +++ b/Sources/SwiftDocC/Utility/Formatting/FragmentBuilder.swift @@ -0,0 +1,215 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import SwiftParser +import SwiftSyntax +import SymbolKit + +extension SymbolGraph.Symbol.DeclarationFragments.Fragment { + init( + spelling: String, + kind: Kind = .text, + preciseIdentifier: String? = nil + ) { + self.init( + kind: kind, + spelling: spelling, + preciseIdentifier: preciseIdentifier + ) + } +} + +extension Trivia { + var text: String? { + guard pieces.count > 0 else { + return nil + } + + var string: String = "" + write(to: &string) + return string + } +} + +extension TokenSyntax { + var leadingText: String? { + leadingTrivia.text + } + + var trailingText: String? { + trailingTrivia.text + } +} + +/// A subclass of `SwiftSyntax.SyntaxVisitor` which can traverse a syntax tree +/// and build up a simpler, flat `Fragment` array representing it. +/// +/// The main job of this class is to help convert a formatted string for a Swift +/// symbol declaration back into a list of fragments that closely resemble how +/// the same code would be presented in a `SymbolKit` symbol graph. +final class FragmentBuilder: SyntaxVisitor { + typealias Fragment = SymbolGraph.Symbol.DeclarationFragments.Fragment + + private var identifiers: [String:String] + private var fragments: [Fragment] + + init() { + identifiers = [:] + fragments = [] + super.init(viewMode: .sourceAccurate) + } + + /// Returns an array of `Fragment` elements that represents the given String + /// of Swift source code. + /// + /// - Parameter source: A string of Swift source code. + /// - Parameter identifiers: A lookup table of symbol names and precise + /// identifiers to map them to. + /// + /// - Returns: An array of `Fragment` elements. + func buildFragments( + from source: String, + identifiers: [String:String] = [:] + ) -> [Fragment] { + let syntax = Parser.parse(source: source) + return buildFragments(from: syntax, identifiers: identifiers) + } + + func buildFragments( + from syntax: some SyntaxProtocol, + identifiers: [String:String] = [:] + ) -> [Fragment] { + self.identifiers = identifiers + fragments = [] + + walk(syntax) + + return fragments + } + + override func visit(_ node: AttributeSyntax) -> SyntaxVisitorContinueKind { + walk(node.atSign) + + let name = node.attributeName.as(TypeSyntaxEnum.self) + switch name { + case .identifierType(let idType): + emitFragments(for: idType.name, as: .attribute) + if let genericArgumentClause = idType.genericArgumentClause { + walk(genericArgumentClause) + } + default: + walk(node.attributeName) + } + + if let leftParen = node.leftParen { + walk(leftParen) + } + if let args = node.arguments { + walk(args) + } + if let rightParen = node.rightParen { + walk(rightParen) + } + + return .skipChildren + } + + override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { + walk(node.attributes) + walk(node.modifiers) + + emitFragments(for: node.firstName, as: .externalParameter) + if let secondName = node.secondName { + emitFragments(for: secondName, as: .internalParameter) + } + + walk(node.colon) + walk(node.type) + if let ellipsis = node.ellipsis { + walk(ellipsis) + } + if let defaultValue = node.defaultValue { + walk(defaultValue) + } + if let trailingComma = node.trailingComma { + walk(trailingComma) + } + + return .skipChildren + } + + override func visit(_ node: IdentifierTypeSyntax) -> SyntaxVisitorContinueKind { + emitFragments(for: node.name, as: .typeIdentifier) + + if let genericArgumentClause = node.genericArgumentClause { + walk(genericArgumentClause) + } + + return .skipChildren + } + + override func visit(_ node: MemberTypeSyntax) -> SyntaxVisitorContinueKind { + walk(node.baseType) + walk(node.period) + + emitFragments(for: node.name, as: .typeIdentifier) + + if let genericArgumentClause = node.genericArgumentClause { + walk(genericArgumentClause) + } + + return .skipChildren + } + + override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind { + let kind: Fragment.Kind = switch token.tokenKind { + case .integerLiteral: .numberLiteral + case .atSign, .keyword: .keyword + case .stringQuote, .stringSegment: .stringLiteral + default: .text + } + emitFragments(for: token, as: kind) + + return .skipChildren + } + + private func emit(fragment: Fragment) { + if let lastFragment = fragments.last, + lastFragment.preciseIdentifier == nil, + fragment.preciseIdentifier == nil, + lastFragment.kind == fragment.kind { + // if we're going to emit the same fragment kind as the last one, + // go ahead and just combine them together into a single fragment + // (unless this is an identifier type) + fragments = fragments.dropLast() + fragments.append(Fragment( + spelling: lastFragment.spelling + fragment.spelling, + kind: lastFragment.kind + )) + } else { + // add a new fragment that has a distinct kind from the last one + var newFragment = fragment + newFragment.preciseIdentifier = identifiers[fragment.spelling] + fragments.append(newFragment) + } + } + + private func emitFragments(for token: TokenSyntax, as kind: Fragment.Kind) { + if let leadingText = token.leadingText { + emit(fragment: Fragment(spelling: leadingText)) + } + + emit(fragment: Fragment(spelling: token.text, kind: kind)) + + if let trailingText = token.trailingText { + emit(fragment: Fragment(spelling: trailingText)) + } + } +} diff --git a/Sources/SwiftDocC/Utility/Formatting/SyntaxFormatter.swift b/Sources/SwiftDocC/Utility/Formatting/SyntaxFormatter.swift new file mode 100644 index 0000000000..f1f75ba950 --- /dev/null +++ b/Sources/SwiftDocC/Utility/Formatting/SyntaxFormatter.swift @@ -0,0 +1,58 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import SwiftFormat + +/// Configurable formatter that can be used to format the way that a string of +/// Swift source code appears using the `swift-format` library. +struct SyntaxFormatter { + var configuration: SwiftFormat.Configuration + + /// Initializes the formatter with a default configuration. + init() { + let indentWidth = 4 + + configuration = SwiftFormat.Configuration() + configuration.tabWidth = indentWidth + configuration.indentation = .spaces(indentWidth) + configuration.lineBreakBeforeEachArgument = true + configuration.lineBreakBeforeEachGenericRequirement = true + configuration.lineBreakBetweenDeclarationAttributes = true + } + + /// Initializes the formatter with the provided configuration. + init(configuration: SwiftFormat.Configuration) { + self.configuration = configuration + } + + /// Format the given string of Swift source code and return a version of it + /// formatted using `swift-format`. + /// + /// - Parameter source: The string of Swift source code. + /// - Returns: The formatted Swift source code. + /// - Throws: An error if `swift-format` encountered an error. + func format(source: String) throws -> String { + var formatted = "" + + try SwiftFormatter(configuration: configuration).format( + source: source, + assumingFileURL: nil, + selection: .infinite, + to: &formatted + ) + + // remove any trailing newline added by swift-format + if let last = formatted.last, last == "\n" { + formatted = String(formatted.dropLast()) + } + + return formatted + } +} diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index 9ef9c7e46f..a958c4eeb5 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -25,6 +25,7 @@ extension ConvertAction { FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation FeatureFlags.current.isExperimentalMentionedInEnabled = convert.enableExperimentalMentionedIn FeatureFlags.current.isParametersAndReturnsValidationEnabled = convert.enableParametersAndReturnsValidation + FeatureFlags.current.isExperimentalDeclarationFormattingEnabled = convert.enableExperimentalDeclarationFormatting // If the user-provided a URL for an external link resolver, attempt to // initialize an `OutOfProcessReferenceResolver` with the provided URL. diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 35a7d603ca..8bcf913e13 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -529,6 +529,9 @@ extension Docc { ) ) var emitLMDBIndex = false + + @Flag(help: "Use swift-format to format Swift symbol declarations.") + var enableExperimentalDeclarationFormatting = false @available(*, deprecated) // This deprecation silences the access of the deprecated `index` flag. mutating func validate() throws { @@ -625,6 +628,15 @@ extension Docc { set { featureFlags.emitLMDBIndex = newValue } } + + /// A user-provided value that is true if swift-format should be used to + /// format Swift symbol declarations. + /// + /// Defaults to false. + public var enableExperimentalDeclarationFormatting: Bool { + get { featureFlags.enableExperimentalDeclarationFormatting } + set { featureFlags.enableExperimentalDeclarationFormatting = newValue } + } // MARK: - ParsableCommand conformance diff --git a/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift b/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift index adbafad02d..1cfd8bfd6e 100644 --- a/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift +++ b/Tests/SwiftDocCTests/Rendering/DeclarationsRenderSectionTests.swift @@ -345,6 +345,67 @@ class DeclarationsRenderSectionTests: XCTestCase { XCTAssert(declarations.tokens.allSatisfy({ $0.highlight == nil })) } } + + func testDeclarationFormatting() throws { + let symbolGraphFile = Bundle.module.url( + forResource: "Testing", + withExtension: "symbols.json", + subdirectory: "Test Resources" + )! + + let tempURL = try createTempFolder(content: [ + Folder(name: "unit-test.docc", content: [ + InfoPlist(displayName: "Testing", identifier: "com.test.example"), + CopyOfFile(original: symbolGraphFile), + ]) + ]) + + let (_, bundle, context) = try loadBundle(from: tempURL) + let reference = ResolvedTopicReference( + bundleIdentifier: bundle.identifier, + path: "/documentation/Testing/Test(_:_:arguments:)", + sourceLanguage: .swift + ) + let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Symbol) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: reference) + + var renderNode = try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode) + var declarationsSection = try XCTUnwrap(renderNode.primaryContentSections.compactMap({ $0 as? DeclarationsRenderSection }).first) + XCTAssertEqual(declarationsSection.declarations.count, 1) + var declaration = try XCTUnwrap(declarationsSection.declarations.first) + // no formatting applied to declaration tokens by default + XCTAssertEqual(extractText(from: declaration.tokens), """ + @attached(peer) macro Test(_ displayName: String? = nil, \ + _ traits: any TestTrait..., arguments zippedCollections: \ + Zip2Sequence) where C1 : Collection, C1 : Sendable, C2 \ + : Collection, C2 : Sendable, C1.Element : Sendable, C2.Element \ + : Sendable + """) + + // swift-format used to format decls if + // --enable-experimental-declaration-formatting flag is used + enableFeatureFlag(\.isExperimentalDeclarationFormattingEnabled) + translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: reference) + renderNode = try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode) + declarationsSection = try XCTUnwrap(renderNode.primaryContentSections.compactMap({ $0 as? DeclarationsRenderSection }).first) + XCTAssertEqual(declarationsSection.declarations.count, 1) + declaration = try XCTUnwrap(declarationsSection.declarations.first) + XCTAssertEqual(extractText(from: declaration.tokens), """ + @attached(peer) + macro Test( + _ displayName: String? = nil, + _ traits: any TestTrait..., + arguments zippedCollections: Zip2Sequence + ) + where + C1: Collection, + C1: Sendable, + C2: Collection, + C2: Sendable, + C1.Element: Sendable, + C2.Element: Sendable + """) + } } /// Render a list of declaration tokens as a plain-text decoration and as a plain-text rendering of which characters are highlighted. @@ -354,3 +415,8 @@ func declarationAndHighlights(for tokens: [DeclarationRenderSection.Token]) -> [ tokens.map({ String(repeating: $0.highlight == .changed ? "~" : " ", count: $0.text.count) }).joined() ] } + + +func extractText(from tokens: [DeclarationRenderSection.Token]) -> String { + tokens.reduce("") { txt, token in "\(txt)\(token.text)" } +} diff --git a/Tests/SwiftDocCTests/Test Resources/Testing.symbols.json b/Tests/SwiftDocCTests/Test Resources/Testing.symbols.json new file mode 100644 index 0000000000..94cee29b91 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Resources/Testing.symbols.json @@ -0,0 +1,744 @@ +{ + "metadata": { + "formatVersion": { + "major": 0, + "minor": 6, + "patch": 0 + }, + "generator": "Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)" + }, + "module": { + "name": "Testing", + "platform": { + "architecture": "arm64", + "vendor": "apple", + "operatingSystem": { + "name": "macosx", + "minimumVersion": { + "major": 10, + "minor": 15 + } + } + } + }, + "symbols": [ + { + "kind": { + "identifier": "swift.macro", + "displayName": "Macro" + }, + "identifier": { + "precise": "s:7Testing4Test__9argumentsySSSgYt_AA0B5Trait_pds12Zip2SequenceVyxq_GtcSlRzs8SendableRzSlR_sAIR_sAI7ElementRpzsAiJRp_r0_lufm", + "interfaceLanguage": "swift" + }, + "pathComponents": [ + "Test(_:_:arguments:)" + ], + "names": { + "title": "Test(_:_:arguments:)", + "subHeading": [ + { + "kind": "keyword", + "spelling": "macro" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Test" + }, + { + "kind": "text", + "spelling": "<" + }, + { + "kind": "genericParameter", + "spelling": "C1" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "genericParameter", + "spelling": "C2" + }, + { + "kind": "text", + "spelling": ">(" + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + }, + { + "kind": "text", + "spelling": "?, " + }, + { + "kind": "keyword", + "spelling": "any" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "typeIdentifier", + "spelling": "TestTrait", + "preciseIdentifier": "s:7Testing9TestTraitP" + }, + { + "kind": "text", + "spelling": "..., " + }, + { + "kind": "externalParam", + "spelling": "arguments" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Zip2Sequence", + "preciseIdentifier": "s:s12Zip2SequenceV" + }, + { + "kind": "text", + "spelling": "<" + }, + { + "kind": "typeIdentifier", + "spelling": "C1", + "preciseIdentifier": "s:7Testing2C1L_xmfp" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C2", + "preciseIdentifier": "s:7Testing2C2L_q_mfp" + }, + { + "kind": "text", + "spelling": ">)" + } + ] + }, + "docComment": { + "uri": "file:///Users/marcus/dev/swiftlang/swift-testing/Sources/Testing/Test+Macro.swift", + "module": "Testing", + "lines": [ + { + "range": { + "start": { + "line": 344, + "character": 4 + }, + "end": { + "line": 344, + "character": 71 + } + }, + "text": "Declare a test parameterized over two zipped collections of values." + }, + { + "range": { + "start": { + "line": 345, + "character": 3 + }, + "end": { + "line": 345, + "character": 3 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 346, + "character": 4 + }, + "end": { + "line": 346, + "character": 17 + } + }, + "text": "- Parameters:" + }, + { + "range": { + "start": { + "line": 347, + "character": 4 + }, + "end": { + "line": 347, + "character": 78 + } + }, + "text": " - displayName: The customized display name of this test. If the value of" + }, + { + "range": { + "start": { + "line": 348, + "character": 4 + }, + "end": { + "line": 348, + "character": 80 + } + }, + "text": " this argument is `nil`, the display name of the test is derived from the" + }, + { + "range": { + "start": { + "line": 349, + "character": 4 + }, + "end": { + "line": 349, + "character": 35 + } + }, + "text": " associated function's name." + }, + { + "range": { + "start": { + "line": 350, + "character": 4 + }, + "end": { + "line": 350, + "character": 58 + } + }, + "text": " - traits: Zero or more traits to apply to this test." + }, + { + "range": { + "start": { + "line": 351, + "character": 4 + }, + "end": { + "line": 351, + "character": 70 + } + }, + "text": " - zippedCollections: Two zipped collections of values to pass to" + }, + { + "range": { + "start": { + "line": 352, + "character": 4 + }, + "end": { + "line": 352, + "character": 23 + } + }, + "text": " `testFunction`." + }, + { + "range": { + "start": { + "line": 353, + "character": 3 + }, + "end": { + "line": 353, + "character": 3 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 354, + "character": 4 + }, + "end": { + "line": 354, + "character": 80 + } + }, + "text": "During testing, the associated test function is called once for each element" + }, + { + "range": { + "start": { + "line": 355, + "character": 4 + }, + "end": { + "line": 355, + "character": 27 + } + }, + "text": "in `zippedCollections`." + }, + { + "range": { + "start": { + "line": 356, + "character": 3 + }, + "end": { + "line": 356, + "character": 3 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 357, + "character": 4 + }, + "end": { + "line": 357, + "character": 14 + } + }, + "text": "@Comment {" + }, + { + "range": { + "start": { + "line": 358, + "character": 4 + }, + "end": { + "line": 358, + "character": 66 + } + }, + "text": " - Bug: The testing library should support variadic generics." + }, + { + "range": { + "start": { + "line": 359, + "character": 4 + }, + "end": { + "line": 359, + "character": 39 + } + }, + "text": " ([103416861](rdar://103416861))" + }, + { + "range": { + "start": { + "line": 360, + "character": 4 + }, + "end": { + "line": 360, + "character": 5 + } + }, + "text": "}" + }, + { + "range": { + "start": { + "line": 361, + "character": 3 + }, + "end": { + "line": 361, + "character": 3 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 362, + "character": 4 + }, + "end": { + "line": 362, + "character": 15 + } + }, + "text": "## See Also" + }, + { + "range": { + "start": { + "line": 363, + "character": 3 + }, + "end": { + "line": 363, + "character": 3 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 364, + "character": 4 + }, + "end": { + "line": 364, + "character": 25 + } + }, + "text": "- " + } + ] + }, + "swiftGenerics": { + "parameters": [ + { + "name": "C1", + "index": 0, + "depth": 0 + }, + { + "name": "C2", + "index": 1, + "depth": 0 + } + ], + "constraints": [ + { + "kind": "conformance", + "lhs": "C1", + "rhs": "Collection", + "rhsPrecise": "s:Sl" + }, + { + "kind": "conformance", + "lhs": "C1", + "rhs": "Sendable", + "rhsPrecise": "s:s8SendableP" + }, + { + "kind": "conformance", + "lhs": "C2", + "rhs": "Collection", + "rhsPrecise": "s:Sl" + }, + { + "kind": "conformance", + "lhs": "C2", + "rhs": "Sendable", + "rhsPrecise": "s:s8SendableP" + }, + { + "kind": "conformance", + "lhs": "C1.Element", + "rhs": "Sendable", + "rhsPrecise": "s:s8SendableP" + }, + { + "kind": "conformance", + "lhs": "C2.Element", + "rhs": "Sendable", + "rhsPrecise": "s:s8SendableP" + } + ] + }, + "declarationFragments": [ + { + "kind": "attribute", + "spelling": "@attached" + }, + { + "kind": "text", + "spelling": "(peer) " + }, + { + "kind": "keyword", + "spelling": "macro" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "identifier", + "spelling": "Test" + }, + { + "kind": "text", + "spelling": "<" + }, + { + "kind": "genericParameter", + "spelling": "C1" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "genericParameter", + "spelling": "C2" + }, + { + "kind": "text", + "spelling": ">(" + }, + { + "kind": "externalParam", + "spelling": "_" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "internalParam", + "spelling": "displayName" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "String", + "preciseIdentifier": "s:SS" + }, + { + "kind": "text", + "spelling": "? = nil, " + }, + { + "kind": "externalParam", + "spelling": "_" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "internalParam", + "spelling": "traits" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "keyword", + "spelling": "any" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "typeIdentifier", + "spelling": "TestTrait", + "preciseIdentifier": "s:7Testing9TestTraitP" + }, + { + "kind": "text", + "spelling": "..., " + }, + { + "kind": "externalParam", + "spelling": "arguments" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "internalParam", + "spelling": "zippedCollections" + }, + { + "kind": "text", + "spelling": ": " + }, + { + "kind": "typeIdentifier", + "spelling": "Zip2Sequence", + "preciseIdentifier": "s:s12Zip2SequenceV" + }, + { + "kind": "text", + "spelling": "<" + }, + { + "kind": "typeIdentifier", + "spelling": "C1", + "preciseIdentifier": "s:7Testing2C1L_xmfp" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C2", + "preciseIdentifier": "s:7Testing2C2L_q_mfp" + }, + { + "kind": "text", + "spelling": ">) " + }, + { + "kind": "keyword", + "spelling": "where" + }, + { + "kind": "text", + "spelling": " " + }, + { + "kind": "typeIdentifier", + "spelling": "C1" + }, + { + "kind": "text", + "spelling": " : " + }, + { + "kind": "typeIdentifier", + "spelling": "Collection", + "preciseIdentifier": "s:Sl" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C1" + }, + { + "kind": "text", + "spelling": " : " + }, + { + "kind": "typeIdentifier", + "spelling": "Sendable", + "preciseIdentifier": "s:s8SendableP" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C2" + }, + { + "kind": "text", + "spelling": " : " + }, + { + "kind": "typeIdentifier", + "spelling": "Collection", + "preciseIdentifier": "s:Sl" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C2" + }, + { + "kind": "text", + "spelling": " : " + }, + { + "kind": "typeIdentifier", + "spelling": "Sendable", + "preciseIdentifier": "s:s8SendableP" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C1" + }, + { + "kind": "text", + "spelling": "." + }, + { + "kind": "typeIdentifier", + "spelling": "Element", + "preciseIdentifier": "s:ST7ElementQa" + }, + { + "kind": "text", + "spelling": " : " + }, + { + "kind": "typeIdentifier", + "spelling": "Sendable", + "preciseIdentifier": "s:s8SendableP" + }, + { + "kind": "text", + "spelling": ", " + }, + { + "kind": "typeIdentifier", + "spelling": "C2" + }, + { + "kind": "text", + "spelling": "." + }, + { + "kind": "typeIdentifier", + "spelling": "Element", + "preciseIdentifier": "s:ST7ElementQa" + }, + { + "kind": "text", + "spelling": " : " + }, + { + "kind": "typeIdentifier", + "spelling": "Sendable", + "preciseIdentifier": "s:s8SendableP" + } + ], + "accessLevel": "public", + "location": { + "uri": "file:///Users/marcus/dev/swiftlang/swift-testing/Sources/Testing/Test+Macro.swift", + "position": { + "line": 365, + "character": 29 + } + } + } + ], + "relationships": [] +} diff --git a/Tests/SwiftDocCTests/Utility/Formatting/FragmentBuilderTests.swift b/Tests/SwiftDocCTests/Utility/Formatting/FragmentBuilderTests.swift new file mode 100644 index 0000000000..d1608bb167 --- /dev/null +++ b/Tests/SwiftDocCTests/Utility/Formatting/FragmentBuilderTests.swift @@ -0,0 +1,106 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +@testable import SwiftDocC + +typealias Fragment = FragmentBuilder.Fragment + +class FragmentBuilderTests: XCTestCase { + func testBuildFragments() { + XCTAssertEqual( + FragmentBuilder().buildFragments( + from: """ + @attached(peer) + macro Test( + _ displayName: String? = nil, + _ traits: any TestTrait..., + arguments zippedCollections: Zip2Sequence + ) + where + C1: Collection, + C1: Sendable, + C2: Collection, + C2: Sendable, + C1.Element: Sendable, + C2.Element: Sendable + """, + identifiers: ["Zip2Sequence":"s:s12Zip2SequenceV"] + ), + [ + Fragment(spelling: "@", kind: .keyword), + Fragment(spelling: "attached", kind: .attribute), + Fragment(spelling: "(peer)\n"), + Fragment(spelling: "macro", kind: .keyword), + Fragment(spelling: " Test(\n "), + Fragment(spelling: "_", kind: .externalParameter), + Fragment(spelling: " "), + Fragment(spelling: "displayName", kind: .internalParameter), + Fragment(spelling: ": "), + Fragment(spelling: "String", kind: .typeIdentifier), + Fragment(spelling: "? = "), + Fragment(spelling: "nil", kind: .keyword), + Fragment(spelling: ",\n "), + Fragment(spelling: "_", kind: .externalParameter), + Fragment(spelling: " "), + Fragment(spelling: "traits", kind: .internalParameter), + Fragment(spelling: ": "), + Fragment(spelling: "any", kind: .keyword), + Fragment(spelling: " "), + Fragment(spelling: "TestTrait", kind: .typeIdentifier), + Fragment(spelling: "...,\n "), + Fragment(spelling: "arguments", kind: .externalParameter), + Fragment(spelling: " "), + Fragment(spelling: "zippedCollections", kind: .internalParameter), + Fragment(spelling: ": "), + Fragment( + spelling: "Zip2Sequence", + kind: .typeIdentifier, + preciseIdentifier: "s:s12Zip2SequenceV" + ), + Fragment(spelling: "<"), + Fragment(spelling: "C1", kind: .typeIdentifier), + Fragment(spelling: ", "), + Fragment(spelling: "C2", kind: .typeIdentifier), + Fragment(spelling: ">\n)\n"), + Fragment(spelling: "where", kind: .keyword), + Fragment(spelling: "\n "), + Fragment(spelling: "C1", kind: .typeIdentifier), + Fragment(spelling: ": "), + Fragment(spelling: "Collection", kind: .typeIdentifier), + Fragment(spelling: ",\n "), + Fragment(spelling: "C1", kind: .typeIdentifier), + Fragment(spelling: ": "), + Fragment(spelling: "Sendable", kind: .typeIdentifier), + Fragment(spelling: ",\n "), + Fragment(spelling: "C2", kind: .typeIdentifier), + Fragment(spelling: ": "), + Fragment(spelling: "Collection", kind: .typeIdentifier), + Fragment(spelling: ",\n "), + Fragment(spelling: "C2", kind: .typeIdentifier), + Fragment(spelling: ": "), + Fragment(spelling: "Sendable", kind: .typeIdentifier), + Fragment(spelling: ",\n "), + Fragment(spelling: "C1", kind: .typeIdentifier), + Fragment(spelling: "."), + Fragment(spelling: "Element", kind: .typeIdentifier), + Fragment(spelling: ": "), + Fragment(spelling: "Sendable", kind: .typeIdentifier), + Fragment(spelling: ",\n "), + Fragment(spelling: "C2", kind: .typeIdentifier), + Fragment(spelling: "."), + Fragment(spelling: "Element", kind: .typeIdentifier), + Fragment(spelling: ": "), + Fragment(spelling: "Sendable", kind: .typeIdentifier), + Fragment(spelling: ""), + ] + ) + } +} diff --git a/Tests/SwiftDocCTests/Utility/Formatting/SyntaxFormatterTests.swift b/Tests/SwiftDocCTests/Utility/Formatting/SyntaxFormatterTests.swift new file mode 100644 index 0000000000..cb583581fa --- /dev/null +++ b/Tests/SwiftDocCTests/Utility/Formatting/SyntaxFormatterTests.swift @@ -0,0 +1,54 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import SwiftFormat +import XCTest +@testable import SwiftDocC + +class SyntaxFormatterTests: XCTestCase { + func testDefaultInitializerUsesDefaultConfig() { + let formatter = SyntaxFormatter() + XCTAssertEqual(formatter.configuration.tabWidth, 4) + } + + func testCustomConfigurationInitializer() { + var config = Configuration() + config.tabWidth = 2 + let formatter = SyntaxFormatter(configuration: config) + XCTAssertEqual(formatter.configuration.tabWidth, 2) + } + + func testFormatWithComplexFunction() throws { + XCTAssertEqual( + try SyntaxFormatter().format(source: """ + @attached(peer) macro Test(_ displayName: String? = nil, \ + _ traits: any TestTrait..., arguments zippedCollections: \ + Zip2Sequence) where C1 : Collection, C1 : Sendable, C2 \ + : Collection, C2 : Sendable, C1.Element : Sendable, C2.Element \ + : Sendable + """), + """ + @attached(peer) + macro Test( + _ displayName: String? = nil, + _ traits: any TestTrait..., + arguments zippedCollections: Zip2Sequence + ) + where + C1: Collection, + C1: Sendable, + C2: Collection, + C2: Sendable, + C1.Element: Sendable, + C2.Element: Sendable + """ + ) + } +} diff --git a/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift b/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift index 073a238582..838103cf5d 100644 --- a/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ArgumentParsing/ConvertSubcommandTests.swift @@ -591,4 +591,25 @@ class ConvertSubcommandTests: XCTestCase { let enabledFlagConvert = try Docc.Convert.parse(["--fallback-bundle-version", "1.2.3"]) XCTAssertEqual(enabledFlagConvert.infoPlistFallbacks._unusedVersionForBackwardsCompatibility, "1.2.3") } + + func testEnableExperimentalDeclarationFormattingFlag() throws { + let originalFeatureFlagsState = FeatureFlags.current + + defer { + FeatureFlags.current = originalFeatureFlagsState + } + + let commandWithoutFlag = try Docc.Convert.parse([testBundleURL.path]) + _ = try ConvertAction(fromConvertCommand: commandWithoutFlag) + XCTAssertFalse(commandWithoutFlag.enableExperimentalDeclarationFormatting) + XCTAssertFalse(FeatureFlags.current.isExperimentalDeclarationFormattingEnabled) + + let commandWithFlag = try Docc.Convert.parse([ + "--enable-experimental-declaration-formatting", + testBundleURL.path, + ]) + _ = try ConvertAction(fromConvertCommand: commandWithFlag) + XCTAssertTrue(commandWithFlag.enableExperimentalDeclarationFormatting) + XCTAssertTrue(FeatureFlags.current.isExperimentalDeclarationFormattingEnabled) + } }