From 9698d861065647fe74cd82bee06d7628eac11c80 Mon Sep 17 00:00:00 2001 From: dankinsoid <30962149+dankinsoid@users.noreply.github.com> Date: Sun, 19 Nov 2023 15:55:17 +0400 Subject: [PATCH] 3.0.0 --- Package.resolved | 9 +++ Package.swift | 13 +++- .../Encoders/OpenAPIDescription.swift | 40 +++++++--- Sources/SwiftOpenAPI/OpenAPIType.swift | 7 ++ .../OpenAPIDescriptionMacro.swift | 75 +++++++++++++++++++ Sources/SwiftOpenAPIMacros/StringError.swift | 10 +++ Sources/SwiftOpenAPIMacros/SyntaxExt.swift | 66 ++++++++++++++++ .../SwiftOpenAPIMacrosTests.swift | 60 +++++++++++++++ 8 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 Sources/SwiftOpenAPIMacros/OpenAPIDescriptionMacro.swift create mode 100644 Sources/SwiftOpenAPIMacros/StringError.swift create mode 100644 Sources/SwiftOpenAPIMacros/SyntaxExt.swift create mode 100644 Tests/SwiftOpenAPITests/SwiftOpenAPIMacrosTests.swift diff --git a/Package.resolved b/Package.resolved index 1b72ad8..49f12d6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.10.3" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index bd0af49..72a48f4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,7 +1,8 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import CompilerPluginSupport let package = Package( name: "SwiftOpenAPI", @@ -16,13 +17,23 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "0.10.3"), + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2") ], targets: [ .target(name: "SwiftOpenAPI", dependencies: []), + .macro( + name: "SwiftOpenAPIMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), .testTarget( name: "SwiftOpenAPITests", dependencies: [ "SwiftOpenAPI", + "SwiftOpenAPIMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), .product(name: "CustomDump", package: "swift-custom-dump"), ], exclude: ["Mocks/"] diff --git a/Sources/SwiftOpenAPI/Encoders/OpenAPIDescription.swift b/Sources/SwiftOpenAPI/Encoders/OpenAPIDescription.swift index 153d0ab..9f48849 100644 --- a/Sources/SwiftOpenAPI/Encoders/OpenAPIDescription.swift +++ b/Sources/SwiftOpenAPI/Encoders/OpenAPIDescription.swift @@ -6,13 +6,7 @@ public protocol OpenAPIDescriptionType { var schemePropertyDescriptions: [String: String] { get } } -extension String: OpenAPIDescriptionType { - - public var openAPISchemeDescription: String? { self } - public var schemePropertyDescriptions: [String: String] { [:] } -} - -public struct OpenAPIDescription: OpenAPIDescriptionType, ExpressibleByStringInterpolation { +public struct OpenAPIDescription: OpenAPIDescriptionType, ExpressibleByStringInterpolation, Equatable { public var openAPISchemeDescription: String? public var schemePropertyDescriptions: [String: String] = [:] @@ -28,12 +22,34 @@ public struct OpenAPIDescription: OpenAPIDescriptionType, Expres public init(stringInterpolation: DefaultStringInterpolation) { openAPISchemeDescription = String(stringInterpolation: stringInterpolation) } +} - public func add(for key: Key, _ description: String) -> OpenAPIDescription { - var result = self - result.schemePropertyDescriptions[key.stringValue] = description - return result - } +extension OpenAPIDescription where Key: CodingKey { + + public func add(for key: Key, _ description: String) -> OpenAPIDescription { + var result = self + result.schemePropertyDescriptions[key.stringValue] = description + return result + } +} + +extension OpenAPIDescription where Key == String { + + public func add(for key: Key, _ description: String) -> OpenAPIDescription { + var result = self + result.schemePropertyDescriptions[key] = description + return result + } +} + +extension OpenAPIDescription where Key: RawRepresentable, Key.RawValue == String { + + @_disfavoredOverload + public func add(for key: Key, _ description: String) -> OpenAPIDescription { + var result = self + result.schemePropertyDescriptions[key.rawValue] = description + return result + } } extension SchemaObject { diff --git a/Sources/SwiftOpenAPI/OpenAPIType.swift b/Sources/SwiftOpenAPI/OpenAPIType.swift index 7ed028b..8bac663 100644 --- a/Sources/SwiftOpenAPI/OpenAPIType.swift +++ b/Sources/SwiftOpenAPI/OpenAPIType.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftOpenAPIMacros public protocol OpenAPIDescriptable { @@ -12,6 +13,12 @@ public extension OpenAPIDescriptable { } } +@attached(extension, conformances: OpenAPIDescriptable, names: arbitrary) +public macro OpenAPIAutoDescriptable() = #externalMacro( + module: "SwiftOpenAPIMacros", + type: "OpenAPIDescriptionMacro" +) + public protocol OpenAPIType: OpenAPIDescriptable { static var openAPISchema: SchemaObject { get } diff --git a/Sources/SwiftOpenAPIMacros/OpenAPIDescriptionMacro.swift b/Sources/SwiftOpenAPIMacros/OpenAPIDescriptionMacro.swift new file mode 100644 index 0000000..7344ffd --- /dev/null +++ b/Sources/SwiftOpenAPIMacros/OpenAPIDescriptionMacro.swift @@ -0,0 +1,75 @@ +#if canImport(SwiftCompilerPlugin) +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxMacros + +@main +struct OpenAPIDescriptionPlugin: CompilerPlugin { + + let providingMacros: [Macro.Type] = [ + OpenAPIDescriptionMacro.self + ] +} + +public struct OpenAPIDescriptionMacro: ExtensionMacro { + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + let typeDoc = declaration + .children(viewMode: .all) + .compactMap(\.documentation) + .first? + .wrapped + let varDocs = declaration.memberBlock.members.compactMap { member -> (String, String)? in + guard + let variable = member.decl.as(VariableDeclSyntax.self), + variable.attributes.isEmpty, + let doc = variable.documentation + else { + return nil + } + + var name: String? + for binding in variable.bindings { + if let identifier = binding.pattern.as(IdentifierPatternSyntax.self) { + name = identifier.identifier.text + } + if let closure = binding.accessorBlock { + guard + let list = closure.accessors.as(AccessorDeclListSyntax.self), + list.contains(where: \.accessorSpecifier.isWillSetOrDidSet) + else { + return nil + } + } + } + return name.map { ($0, doc) } + } + let varDocsModifiers = varDocs.map { + "\n .add(for: \"\($0.0)\", \($0.1.wrapped))" + } + .joined() + + let sendableExtension: DeclSyntax = + """ + extension \(type.trimmed): OpenAPIDescriptable { + + public static var openAPIDescription: OpenAPIDescriptionType? { + OpenAPIDescription(\(raw: typeDoc ?? ""))\(raw: varDocsModifiers) + } + } + """ + + guard let extensionDecl = sendableExtension.as(ExtensionDeclSyntax.self) else { + throw StringError("Failed to create extension declaration") + } + + return [extensionDecl] + } +} +#endif diff --git a/Sources/SwiftOpenAPIMacros/StringError.swift b/Sources/SwiftOpenAPIMacros/StringError.swift new file mode 100644 index 0000000..e093f01 --- /dev/null +++ b/Sources/SwiftOpenAPIMacros/StringError.swift @@ -0,0 +1,10 @@ +import Foundation + +struct StringError: LocalizedError { + + var errorDescription: String? + + init(_ errorDescription: String? = nil) { + self.errorDescription = errorDescription + } +} diff --git a/Sources/SwiftOpenAPIMacros/SyntaxExt.swift b/Sources/SwiftOpenAPIMacros/SyntaxExt.swift new file mode 100644 index 0000000..b3866a1 --- /dev/null +++ b/Sources/SwiftOpenAPIMacros/SyntaxExt.swift @@ -0,0 +1,66 @@ +#if canImport(SwiftCompilerPlugin) +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxMacros + +extension TokenSyntax { + + var isWillSetOrDidSet: Bool { + self == .keyword(.didSet) || self == .keyword(.willSet) + } +} + +extension SyntaxProtocol { + + var documentation: String? { + leadingTrivia.documentation + } +} + +extension Trivia { + + var documentation: String? { + let lines = compactMap { $0.documentation } + guard lines.count > 1 else { return lines.first?.trimmingCharacters(in: .whitespaces) } + + let indentation = lines.compactMap { $0.firstIndex(where: { !$0.isWhitespace })?.utf16Offset(in: $0) } + .min() ?? 0 + + return lines.map { + guard $0.count > indentation else { return String($0) } + return String($0.suffix($0.count - indentation)) + }.joined(separator: "\\n") + } +} + +extension TriviaPiece { + + var documentation: String? { + switch self { + case let .docLineComment(comment): + let startIndex = comment.index(comment.startIndex, offsetBy: 3) + return String(comment.suffix(from: startIndex)) + case let .lineComment(comment): + let startIndex = comment.index(comment.startIndex, offsetBy: 2) + return String(comment.suffix(from: startIndex)) + case let .docBlockComment(comment): + let startIndex = comment.index(comment.startIndex, offsetBy: 3) + let endIndex = comment.index(comment.endIndex, offsetBy: -2) + return String(comment[startIndex ..< endIndex]) + case let .blockComment(comment): + let startIndex = comment.index(comment.startIndex, offsetBy: 2) + let endIndex = comment.index(comment.endIndex, offsetBy: -2) + return String(comment[startIndex ..< endIndex]) + default: + return nil + } + } +} + +extension String { + + var wrapped: String { + "#\"\(self)\"#" + } +} +#endif diff --git a/Tests/SwiftOpenAPITests/SwiftOpenAPIMacrosTests.swift b/Tests/SwiftOpenAPITests/SwiftOpenAPIMacrosTests.swift new file mode 100644 index 0000000..b43176e --- /dev/null +++ b/Tests/SwiftOpenAPITests/SwiftOpenAPIMacrosTests.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest +@testable import SwiftOpenAPIMacros +@testable import SwiftOpenAPI + +let testMacros: [String: Macro.Type] = [ + "OpenAPIAutoDescriptable": OpenAPIDescriptionMacro.self +] + +final class OpenAPIDescriptionMacroTests: XCTestCase { + + func test_should_create_extension() { + assertMacroExpansion( + """ + /// A person. + @OpenAPIAutoDescriptable + struct Person: Codable { + + /// The person's name. + let name: String + } + """, + expandedSource: """ + /// A person. + struct Person: Codable { + + /// The person's name. + let name: String + } + + extension Person: OpenAPIDescriptable { + + public static var openAPIDescription: OpenAPIDescriptionType? { + OpenAPIDescription(#"A person."#) + .add(for: "name", #"The person's name."#) + } + } + """, + macros: testMacros + ) + } + + func test_created_extension() { + XCTAssertEqual( + Person.openAPIDescription as? OpenAPIDescription, + OpenAPIDescription("A person.") + .add(for: "name", "The person's name.") + ) + } +} + +@OpenAPIAutoDescriptable +/// A person. +struct Person { + + /// The person's name. + let name: String +}