From 0ae57b9d3728b288fa8d917ffb8cbda55b2598ea Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 8 Aug 2023 10:07:00 +0200 Subject: [PATCH] [Generator] Support unexploded query items (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Generator] Support unexploded query items ### Motivation Depends on https://github.com/apple/swift-openapi-runtime/pull/35. Fixes https://github.com/apple/swift-openapi-generator/issues/52. By default, query items are encoded as exploded (`key=value1&key=value2`), but OpenAPI allows explicitly requesting them unexploded (`key=value1,value2`). This feature missing has shown up in a few OpenAPI documents recently. ### Modifications Adapt the generator to provide the two new `style` and `explode` parameters to query item encoding/decoding functions. ### Result We now support unexploded query items. ### Test Plan Expanded snippet-based tests to allow generating not just types, but also parts of the client/server. This has allowed us to not have to expand file-based reference tests here. Reviewed by: glbrntt Builds: ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. https://github.com/apple/swift-openapi-generator/pull/171 --- Package.swift | 2 +- .../StructuredSwiftRepresentation.swift | 5 + .../Renderer/TextBasedRenderer.swift | 2 + .../Translator/CommonTypes/Constants.swift | 7 + .../Parameters/TypedParameter.swift | 43 +++- .../Parameters/translateParameter.swift | 17 +- .../TypesTranslator/TypesFileTranslator.swift | 2 +- .../Articles/Supported-OpenAPI-features.md | 14 +- .../StructureHelpers.swift | 8 +- .../ReferenceSources/Petstore/Client.swift | 8 + .../ReferenceSources/Petstore/Server.swift | 8 + .../Client.swift | 8 + .../Server.swift | 8 + .../SnippetBasedReferenceTests.swift | 228 ++++++++++++++++++ 14 files changed, 340 insertions(+), 20 deletions(-) diff --git a/Package.swift b/Package.swift index 76f840bb..d1b0ddd8 100644 --- a/Package.swift +++ b/Package.swift @@ -78,7 +78,7 @@ let package = Package( ), // Tests-only: Runtime library linked by generated code - .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.7")), + .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.8")), // Build and preview docs .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), diff --git a/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift b/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift index 78aaf013..6e63a0e0 100644 --- a/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift +++ b/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift @@ -97,6 +97,11 @@ enum LiteralDescription: Equatable, Codable { /// For example `42`. case int(Int) + /// A Boolean literal. + /// + /// For example `true`. + case bool(Bool) + /// The nil literal: `nil`. case `nil` diff --git a/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift b/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift index b004dd12..72c199c6 100644 --- a/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift +++ b/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift @@ -316,6 +316,8 @@ struct TextBasedRenderer: RendererProtocol { return "\"\(string)\"" case let .int(int): return "\(int)" + case let .bool(bool): + return bool ? "true" : "false" case .nil: return "nil" case .array(let items): diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift index 95b8d097..5809bae6 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift @@ -299,6 +299,13 @@ enum Constants { /// The name of the namespace. static let namespace: String = "Parameters" + + /// Maps to `OpenAPIRuntime.ParameterStyle`. + enum Style { + + /// The form style. + static let form = "form" + } } /// Constants related to the Headers namespace. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Parameters/TypedParameter.swift b/Sources/_OpenAPIGeneratorCore/Translator/Parameters/TypedParameter.swift index ce9974f4..a7e7a847 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Parameters/TypedParameter.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Parameters/TypedParameter.swift @@ -22,6 +22,12 @@ struct TypedParameter { /// The underlying schema. var schema: UnresolvedSchema + /// The parameter serialization style. + var style: OpenAPI.Parameter.SchemaContext.Style + + /// The parameter explode value. + var explode: Bool + /// The computed type usage. var typeUsage: TypeUsage @@ -134,9 +140,13 @@ extension FileTranslator { let schema: UnresolvedSchema let codingStrategy: CodingStrategy + let style: OpenAPI.Parameter.SchemaContext.Style + let explode: Bool switch parameter.schemaOrContent { case let .a(schemaContext): schema = schemaContext.schema + style = schemaContext.style + explode = schemaContext.explode codingStrategy = .text // Check supported exploded/style types @@ -150,13 +160,6 @@ extension FileTranslator { ) return nil } - guard schemaContext.explode else { - diagnostics.emitUnsupported( - "Unexploded query params", - foundIn: foundIn - ) - return nil - } case .header, .path: guard case .simple = schemaContext.style else { diagnostics.emitUnsupported( @@ -189,6 +192,17 @@ extension FileTranslator { .content .contentType .codingStrategy + + // Defaults are defined by the OpenAPI specification: + // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields-10 + switch parameter.location { + case .query, .cookie: + style = .form + explode = true + case .path, .header: + style = .simple + explode = false + } } // Check if the underlying schema is supported @@ -221,6 +235,8 @@ extension FileTranslator { return .init( parameter: parameter, schema: schema, + style: style, + explode: explode, typeUsage: usage, codingStrategy: codingStrategy, asSwiftSafeName: swiftSafeName @@ -271,3 +287,16 @@ extension OpenAPI.Parameter.Context.Location { } } } + +extension OpenAPI.Parameter.SchemaContext.Style { + + /// The runtime name for the style. + var runtimeName: String { + switch self { + case .form: + return Constants.Components.Parameters.Style.form + default: + preconditionFailure("Unsupported style") + } + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Parameters/translateParameter.swift b/Sources/_OpenAPIGeneratorCore/Translator/Parameters/translateParameter.swift index eb264dad..4a125508 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Parameters/translateParameter.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Parameters/translateParameter.swift @@ -116,13 +116,16 @@ extension ClientFileTranslator { ) throws -> Expression? { let methodPrefix: String let containerExpr: Expression + let supportsStyleAndExplode: Bool switch parameter.location { case .header: methodPrefix = "HeaderField" containerExpr = .identifier(requestVariableName).dot("headerFields") + supportsStyleAndExplode = false case .query: methodPrefix = "QueryItem" containerExpr = .identifier(requestVariableName) + supportsStyleAndExplode = true default: diagnostics.emitUnsupported( "Parameter of type \(parameter.location.rawValue)", @@ -130,6 +133,15 @@ extension ClientFileTranslator { ) return nil } + let styleAndExplodeArgs: [FunctionArgumentDescription] + if supportsStyleAndExplode { + styleAndExplodeArgs = [ + .init(label: "style", expression: .dot(parameter.style.runtimeName)), + .init(label: "explode", expression: .literal(.bool(parameter.explode))), + ] + } else { + styleAndExplodeArgs = [] + } return .try( .identifier("converter") .dot("set\(methodPrefix)As\(parameter.codingStrategy.runtimeName)") @@ -138,7 +150,8 @@ extension ClientFileTranslator { .init( label: "in", expression: .inOut(containerExpr) - ), + ) + ] + styleAndExplodeArgs + [ .init(label: "name", expression: .literal(parameter.name)), .init( label: "value", @@ -194,6 +207,8 @@ extension ServerFileTranslator { .identifier("converter").dot(methodName("QueryItem")) .call([ .init(label: "in", expression: .identifier("metadata").dot("queryParameters")), + .init(label: "style", expression: .dot(typedParameter.style.runtimeName)), + .init(label: "explode", expression: .literal(.bool(typedParameter.explode))), .init(label: "name", expression: .literal(parameter.name)), .init( label: "as", diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift index 7aa46c68..23e3432f 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift @@ -48,7 +48,7 @@ struct TypesFileTranslator: FileTranslator { let components = try translateComponents(doc.components) let operationDescriptions = try OperationDescription.all( - from: parsedOpenAPI.paths, + from: doc.paths, in: doc.components, asSwiftSafeName: swiftSafeName ) diff --git a/Sources/swift-openapi-generator/Documentation.docc/Articles/Supported-OpenAPI-features.md b/Sources/swift-openapi-generator/Documentation.docc/Articles/Supported-OpenAPI-features.md index 16cbdb33..fd127dfb 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Articles/Supported-OpenAPI-features.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Articles/Supported-OpenAPI-features.md @@ -81,7 +81,7 @@ Supported features are always provided on _both_ client and server. - [x] requestBody - [x] responses - [ ] callbacks -- [ ] deprecated +- [x] deprecated - [ ] security - [ ] servers @@ -162,7 +162,7 @@ Supported features are always provided on _both_ client and server. - [ ] xml - [ ] externalDocs - [ ] example -- [ ] deprecated +- [x] deprecated #### External Documentation Object @@ -196,15 +196,15 @@ Supported features are always provided on _both_ client and server. - [x] in - [x] description - [x] required -- [ ] deprecated +- [x] deprecated - [ ] allowEmptyValue -- [x] style (not all) -- [x] explode (only explode: `true`) +- [x] style (only defaults) +- [x] explode (non default only for query items) - [ ] allowReserved - [x] schema - [ ] example - [ ] examples -- [ ] content +- [x] content (chooses one from the map) #### Style Values @@ -223,7 +223,7 @@ Supported features are always provided on _both_ client and server. Supported location + styles + exploded combinations: - path + simple + false -- query + form + true +- query + form + true/false - header + simple + false #### Reference Object diff --git a/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift b/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift index 8e663f07..823dd10d 100644 --- a/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift +++ b/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift @@ -156,13 +156,15 @@ extension Declaration { extension LiteralDescription { var name: String { switch self { - case .string(_): + case .string: return "string" - case .int(_): + case .int: return "int" + case .bool: + return "bool" case .nil: return "nil" - case .array(_): + case .array: return "array" } } diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift index 363e2337..7ac7b329 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift @@ -52,16 +52,22 @@ public struct Client: APIProtocol { suppressMutabilityWarning(&request) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "limit", value: input.query.limit ) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "habitat", value: input.query.habitat ) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "feeds", value: input.query.feeds ) @@ -72,6 +78,8 @@ public struct Client: APIProtocol { ) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "since", value: input.query.since ) diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift index cd60e0cd..b53efce2 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift @@ -87,21 +87,29 @@ fileprivate extension UniversalServer where APIHandler: APIProtocol { let query: Operations.listPets.Input.Query = .init( limit: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "limit", as: Swift.Int32.self ), habitat: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "habitat", as: Operations.listPets.Input.Query.habitatPayload.self ), feeds: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "feeds", as: Operations.listPets.Input.Query.feedsPayload.self ), since: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "since", as: Components.Parameters.query_born_since.self ) diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Client.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Client.swift index 2231d78c..ae604978 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Client.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Client.swift @@ -52,16 +52,22 @@ public struct Client: APIProtocol { suppressMutabilityWarning(&request) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "limit", value: input.query.limit ) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "habitat", value: input.query.habitat ) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "feeds", value: input.query.feeds ) @@ -72,6 +78,8 @@ public struct Client: APIProtocol { ) try converter.setQueryItemAsText( in: &request, + style: .form, + explode: true, name: "since", value: input.query.since ) diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Server.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Server.swift index 1a3753f8..0d4a3472 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Server.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Server.swift @@ -87,21 +87,29 @@ fileprivate extension UniversalServer where APIHandler: APIProtocol { let query: Operations.listPets.Input.Query = .init( limit: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "limit", as: Swift.Int32.self ), habitat: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "habitat", as: Operations.listPets.Input.Query.habitatPayload.self ), feeds: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "feeds", as: Operations.listPets.Input.Query.feedsPayload.self ), since: try converter.getOptionalQueryItemAsText( in: metadata.queryParameters, + style: .form, + explode: true, name: "since", as: Components.Parameters.query_born_since.self ) diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index 205307f9..ad2ee96e 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -969,6 +969,142 @@ final class SnippetBasedReferenceTests: XCTestCase { XCTAssert(error is DecodingError) } } + + func testRequestWithQueryItems() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + get: + parameters: + - name: single + in: query + schema: + type: string + - name: manyExploded + in: query + explode: true + schema: + type: array + items: + type: string + - name: manyUnexploded + in: query + explode: false + schema: + type: array + items: + type: string + responses: + default: + description: Response + """, + types: """ + public struct Input: Sendable, Equatable, Hashable { + public struct Path: Sendable, Equatable, Hashable { public init() {} } + public var path: Operations.get_foo.Input.Path + public struct Query: Sendable, Equatable, Hashable { + public var single: Swift.String? + public var manyExploded: [Swift.String]? + public var manyUnexploded: [Swift.String]? + public init( + single: Swift.String? = nil, + manyExploded: [Swift.String]? = nil, + manyUnexploded: [Swift.String]? = nil + ) { + self.single = single + self.manyExploded = manyExploded + self.manyUnexploded = manyUnexploded + } + } + public var query: Operations.get_foo.Input.Query + public struct Headers: Sendable, Equatable, Hashable { public init() {} } + public var headers: Operations.get_foo.Input.Headers + public struct Cookies: Sendable, Equatable, Hashable { public init() {} } + public var cookies: Operations.get_foo.Input.Cookies + @frozen public enum Body: Sendable, Equatable, Hashable {} + public var body: Operations.get_foo.Input.Body? + public init( + path: Operations.get_foo.Input.Path = .init(), + query: Operations.get_foo.Input.Query = .init(), + headers: Operations.get_foo.Input.Headers = .init(), + cookies: Operations.get_foo.Input.Cookies = .init(), + body: Operations.get_foo.Input.Body? = nil + ) { + self.path = path + self.query = query + self.headers = headers + self.cookies = cookies + self.body = body + } + } + """, + client: """ + { input in let path = try converter.renderedRequestPath(template: "/foo", parameters: []) + var request: OpenAPIRuntime.Request = .init(path: path, method: .get) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsText( + in: &request, + style: .form, + explode: true, + name: "single", + value: input.query.single + ) + try converter.setQueryItemAsText( + in: &request, + style: .form, + explode: true, + name: "manyExploded", + value: input.query.manyExploded + ) + try converter.setQueryItemAsText( + in: &request, + style: .form, + explode: false, + name: "manyUnexploded", + value: input.query.manyUnexploded + ) + return request + } + """, + server: """ + { request, metadata in let path: Operations.get_foo.Input.Path = .init() + let query: Operations.get_foo.Input.Query = .init( + single: try converter.getOptionalQueryItemAsText( + in: metadata.queryParameters, + style: .form, + explode: true, + name: "single", + as: Swift.String.self + ), + manyExploded: try converter.getOptionalQueryItemAsText( + in: metadata.queryParameters, + style: .form, + explode: true, + name: "manyExploded", + as: [Swift.String].self + ), + manyUnexploded: try converter.getOptionalQueryItemAsText( + in: metadata.queryParameters, + style: .form, + explode: false, + name: "manyUnexploded", + as: [Swift.String].self + ) + ) + let headers: Operations.get_foo.Input.Headers = .init() + let cookies: Operations.get_foo.Input.Cookies = .init() + return Operations.get_foo.Input( + path: path, + query: query, + headers: headers, + cookies: cookies, + body: nil + ) + } + """ + ) + } + } extension SnippetBasedReferenceTests { @@ -994,6 +1130,31 @@ extension SnippetBasedReferenceTests { ) } + func makeTranslators( + components: OpenAPI.Components = .noComponents, + featureFlags: FeatureFlags = [], + ignoredDiagnosticMessages: Set = [] + ) throws -> (TypesFileTranslator, ClientFileTranslator, ServerFileTranslator) { + let collector = XCTestDiagnosticCollector(test: self, ignoredDiagnosticMessages: ignoredDiagnosticMessages) + return ( + TypesFileTranslator( + config: Config(mode: .types, featureFlags: featureFlags), + diagnostics: collector, + components: components + ), + ClientFileTranslator( + config: Config(mode: .client, featureFlags: featureFlags), + diagnostics: collector, + components: components + ), + ServerFileTranslator( + config: Config(mode: .server, featureFlags: featureFlags), + diagnostics: collector, + components: components + ) + ) + } + func assertHeadersTranslation( _ componentsYAML: String, _ expectedSwift: String, @@ -1016,6 +1177,45 @@ extension SnippetBasedReferenceTests { try XCTAssertSwiftEquivalent(translation, expectedSwift, file: file, line: line) } + func assertRequestInTypesClientServerTranslation( + _ pathsYAML: String, + _ componentsYAML: String? = nil, + types expectedTypesSwift: String, + client expectedClientSwift: String, + server expectedServerSwift: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + continueAfterFailure = false + let (types, client, server) = try makeTranslators() + let components = + try componentsYAML.flatMap { componentsYAML in + try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) + } ?? OpenAPI.Components.noComponents + let paths = try YAMLDecoder().decode(OpenAPI.PathItem.Map.self, from: pathsYAML) + let document = OpenAPI.Document( + openAPIVersion: .v3_0_3, + info: .init(title: "Test", version: "1.0.0"), + servers: [], + paths: paths, + components: components + ) + let operationDescriptions = try OperationDescription.all( + from: document.paths, + in: document.components, + asSwiftSafeName: types.swiftSafeName + ) + let operation = try XCTUnwrap(operationDescriptions.first) + let generatedTypesStructuredSwift = try types.translateOperationInput(operation) + try XCTAssertSwiftEquivalent(generatedTypesStructuredSwift, expectedTypesSwift, file: file, line: line) + + let generatedClientStructuredSwift = try client.translateClientSerializer(operation) + try XCTAssertSwiftEquivalent(generatedClientStructuredSwift, expectedClientSwift, file: file, line: line) + + let generatedServerStructuredSwift = try server.translateServerDeserializer(operation) + try XCTAssertSwiftEquivalent(generatedServerStructuredSwift, expectedServerSwift, file: file, line: line) + } + func assertSchemasTranslation( _ componentsYAML: String, _ expectedSwift: String, @@ -1106,6 +1306,34 @@ private func XCTAssertSwiftEquivalent( ) } +private func XCTAssertSwiftEquivalent( + _ codeBlock: CodeBlock, + _ expectedSwift: String, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try XCTAssertEqualWithDiff( + TextBasedRenderer().renderedCodeBlock(codeBlock).swiftFormatted, + expectedSwift.swiftFormatted, + file: file, + line: line + ) +} + +private func XCTAssertSwiftEquivalent( + _ expression: Expression, + _ expectedSwift: String, + file: StaticString = #filePath, + line: UInt = #line +) throws { + try XCTAssertEqualWithDiff( + TextBasedRenderer().renderedExpression(expression).swiftFormatted, + expectedSwift.swiftFormatted, + file: file, + line: line + ) +} + private func diff(expected: String, actual: String) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env")