diff --git a/NOTICES.txt b/NOTICES.txt index 17394ee7c..f4807b7f9 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -61,7 +61,8 @@ This product uses derivations of swift-extras/swift-extras-base64 'Base64.swift' --- This product uses derivations of apple/swift-openapi-generator 'StructuredSwiftRepresentation.swift', -'TypeName.swift', 'TypeUsage.swift' and 'Builtins.swift'. +'TypeName.swift', 'TypeUsage.swift', 'Builtins.swift', 'RendererProtocol.swift', 'TextBasedProtocol', +and 'Test_TextBasedRenderer'. * LICENSE (Apache License 2.0): * https://github.com/apple/swift-openapi-generator/blob/main/LICENSE.txt diff --git a/Package.swift b/Package.swift index 00a655337..30b1d8782 100644 --- a/Package.swift +++ b/Package.swift @@ -216,7 +216,7 @@ extension Target { .nioEmbedded, .nioTransportServices, .logging, - .reflectionService, + .reflectionService ].appending( .nioSSL, if: includeNIOSSL ), @@ -235,6 +235,13 @@ extension Target { ] ) + static let grpcCodeGenTests: Target = .testTarget( + name: "GRPCCodeGenTests", + dependencies: [ + .grpcCodeGen + ] + ) + static let interopTestModels: Target = .target( name: "GRPCInteroperabilityTestModels", dependencies: [ @@ -559,9 +566,11 @@ let package = Package( // v2 .grpcCore, + .grpcCodeGen, // v2 tests .grpcCoreTests, + .grpcCodeGenTests ] ) diff --git a/Sources/GRPCCodeGen/Internal/Renderer/RendererProtocol.swift b/Sources/GRPCCodeGen/Internal/Renderer/RendererProtocol.swift new file mode 100644 index 000000000..a08700e65 --- /dev/null +++ b/Sources/GRPCCodeGen/Internal/Renderer/RendererProtocol.swift @@ -0,0 +1,44 @@ +/* + * Copyright 2023, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An object that renders structured Swift representations +/// into Swift files. +/// +/// Rendering is the last phase of the generator pipeline. +protocol RendererProtocol { + + /// Renders the specified structured code into a raw Swift file. + /// - Parameters: + /// - code: A structured representation of the Swift code. + /// - config: The configuration of the generator. + /// - diagnostics: The collector to which to emit diagnostics. + /// - Returns: A raw file with Swift contents. + /// - Throws: An error if an issue occurs during rendering. + func render(structured code: StructuredSwiftRepresentation) throws -> SourceFile +} diff --git a/Sources/GRPCCodeGen/Internal/Renderer/TextBasedRenderer.swift b/Sources/GRPCCodeGen/Internal/Renderer/TextBasedRenderer.swift new file mode 100644 index 000000000..0f58e0390 --- /dev/null +++ b/Sources/GRPCCodeGen/Internal/Renderer/TextBasedRenderer.swift @@ -0,0 +1,937 @@ +/* + * Copyright 2023, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import Foundation + +/// An object for building up a generated file line-by-line. +/// +/// After creation, make calls such as `writeLine` to build up the file, +/// and call `rendered` at the end to get the full file contents. +final class StringCodeWriter { + + /// The stored lines of code. + private var lines: [String] + + /// The current nesting level. + private var level: Int + + /// Whether the next call to `writeLine` will continue writing to the last + /// stored line. Otherwise a new line is appended. + private var nextWriteAppendsToLastLine: Bool = false + + /// Creates a new empty writer. + init() { + self.level = 0 + self.lines = [] + } + + /// Concatenates the stored lines of code into a single string. + /// - Returns: The contents of the full file in a single string. + func rendered() -> String { lines.joined(separator: "\n") } + + /// Writes a line of code. + /// + /// By default, a new line is appended to the file. + /// + /// To continue the last line, make a call to `nextLineAppendsToLastLine` + /// before calling `writeLine`. + /// - Parameter line: The contents of the line to write. + func writeLine(_ line: String) { + let newLine: String + if nextWriteAppendsToLastLine && !lines.isEmpty { + let existingLine = lines.removeLast() + newLine = existingLine + line + } else { + let indentation = Array(repeating: " ", count: 4 * level).joined() + newLine = indentation + line + } + lines.append(newLine) + nextWriteAppendsToLastLine = false + } + + /// Increases the indentation level by 1. + func push() { level += 1 } + + /// Decreases the indentation level by 1. + /// - Precondition: Current level must be greater than 0. + func pop() { + precondition(level > 0, "Cannot pop below 0") + level -= 1 + } + + /// Executes the provided closure with one level deeper indentation. + /// - Parameter work: The closure to execute. + /// - Returns: The result of the closure execution. + func withNestedLevel(_ work: () -> R) -> R { + push() + defer { pop() } + return work() + } + + /// Sets a flag on the writer so that the next call to `writeLine` continues + /// the last stored line instead of starting a new line. + /// + /// Safe to call repeatedly, it gets reset by `writeLine`. + func nextLineAppendsToLastLine() { nextWriteAppendsToLastLine = true } +} + +/// A renderer that uses string interpolation and concatenation +/// to convert the provided structure code into raw string form. +struct TextBasedRenderer: RendererProtocol { + + func render( + structured: StructuredSwiftRepresentation + ) throws + -> SourceFile + { + let namedFile = structured.file + renderFile(namedFile.contents) + let string = writer.rendered() + return SourceFile(name: namedFile.name, contents: string) + } + + /// The underlying writer. + private let writer: StringCodeWriter + + /// Creates a new empty renderer. + static var `default`: TextBasedRenderer { .init(writer: StringCodeWriter()) } + + // MARK: - Internals + + /// Returns the current contents of the writer as a string. + func renderedContents() -> String { writer.rendered() } + + /// Renders the specified Swift file. + func renderFile(_ description: FileDescription) { + if let topComment = description.topComment { renderComment(topComment) } + if let imports = description.imports { renderImports(imports) } + for codeBlock in description.codeBlocks { + renderCodeBlock(codeBlock) + writer.writeLine("") + } + } + + /// Renders the specified comment. + func renderComment(_ comment: Comment) { + let prefix: String + let commentString: String + switch comment { + case .inline(let string): + prefix = "//" + commentString = string + case .doc(let string): + prefix = "///" + commentString = string + case .mark(let string, sectionBreak: true): + prefix = "// MARK: -" + commentString = string + case .mark(let string, sectionBreak: false): + prefix = "// MARK:" + commentString = string + } + let lines = commentString.transformingLines { line in + if line.isEmpty { return prefix } + return "\(prefix) \(line)" + } + lines.forEach(writer.writeLine) + } + + /// Renders the specified import statements. + func renderImports(_ imports: [ImportDescription]?) { (imports ?? []).forEach(renderImport) } + + /// Renders a single import statement. + func renderImport(_ description: ImportDescription) { + func render(preconcurrency: Bool) { + let spiPrefix = description.spi.map { "@_spi(\($0)) " } ?? "" + let preconcurrencyPrefix = preconcurrency ? "@preconcurrency " : "" + if let moduleTypes = description.moduleTypes { + for type in moduleTypes { + writer.writeLine("\(preconcurrencyPrefix)\(spiPrefix)import \(type)") + } + } else { + writer.writeLine("\(preconcurrencyPrefix)\(spiPrefix)import \(description.moduleName)") + } + } + + switch description.preconcurrency { + case .always: render(preconcurrency: true) + case .never: render(preconcurrency: false) + case .onOS(let operatingSystems): + writer.writeLine("#if \(operatingSystems.map { "os(\($0))" }.joined(separator: " || "))") + render(preconcurrency: true) + writer.writeLine("#else") + render(preconcurrency: false) + writer.writeLine("#endif") + } + } + + /// Renders the specified access modifier. + func renderedAccessModifier(_ accessModifier: AccessModifier) -> String { + switch accessModifier { + case .public: return "public" + case .package: return "package" + case .internal: return "internal" + case .fileprivate: return "fileprivate" + case .private: return "private" + } + } + + /// Renders the specified identifier. + func renderedIdentifier(_ identifier: IdentifierDescription) -> String { + switch identifier { + case .pattern(let string): return string + case .type(let existingTypeDescription): + return renderedExistingTypeDescription(existingTypeDescription) + } + } + + /// Renders the specified member access expression. + func renderMemberAccess(_ memberAccess: MemberAccessDescription) { + if let left = memberAccess.left { + renderExpression(left) + writer.nextLineAppendsToLastLine() + } + writer.writeLine(".\(memberAccess.right)") + } + + /// Renders the specified function call argument. + func renderFunctionCallArgument(_ arg: FunctionArgumentDescription) { + if let left = arg.label { + writer.writeLine("\(left): ") + writer.nextLineAppendsToLastLine() + } + renderExpression(arg.expression) + } + + /// Renders the specified function call. + func renderFunctionCall(_ functionCall: FunctionCallDescription) { + renderExpression(functionCall.calledExpression) + writer.nextLineAppendsToLastLine() + writer.writeLine("(") + let arguments = functionCall.arguments + if arguments.count > 1 { + writer.withNestedLevel { + for (argument, isLast) in arguments.enumeratedWithLastMarker() { + renderFunctionCallArgument(argument) + if !isLast { + writer.nextLineAppendsToLastLine() + writer.writeLine(",") + } + } + } + } else { + writer.nextLineAppendsToLastLine() + if let argument = arguments.first { renderFunctionCallArgument(argument) } + writer.nextLineAppendsToLastLine() + } + writer.writeLine(")") + if let trailingClosure = functionCall.trailingClosure { + writer.nextLineAppendsToLastLine() + writer.writeLine(" ") + renderClosureInvocation(trailingClosure) + } + } + + /// Renders the specified assignment expression. + func renderAssignment(_ assignment: AssignmentDescription) { + renderExpression(assignment.left) + writer.nextLineAppendsToLastLine() + writer.writeLine(" = ") + writer.nextLineAppendsToLastLine() + renderExpression(assignment.right) + } + + /// Renders the specified switch case kind. + func renderSwitchCaseKind(_ kind: SwitchCaseKind) { + switch kind { + case let .`case`(expression, associatedValueNames): + let associatedValues: String + let maybeLet: String + if !associatedValueNames.isEmpty { + associatedValues = "(" + associatedValueNames.joined(separator: ", ") + ")" + maybeLet = "let " + } else { + associatedValues = "" + maybeLet = "" + } + writer.writeLine("case \(maybeLet)") + writer.nextLineAppendsToLastLine() + renderExpression(expression) + writer.nextLineAppendsToLastLine() + writer.writeLine(associatedValues) + case .multiCase(let expressions): + writer.writeLine("case ") + writer.nextLineAppendsToLastLine() + for (expression, isLast) in expressions.enumeratedWithLastMarker() { + renderExpression(expression) + writer.nextLineAppendsToLastLine() + if !isLast { writer.writeLine(", ") } + writer.nextLineAppendsToLastLine() + } + case .`default`: writer.writeLine("default") + } + } + + /// Renders the specified switch case. + func renderSwitchCase(_ switchCase: SwitchCaseDescription) { + renderSwitchCaseKind(switchCase.kind) + writer.nextLineAppendsToLastLine() + writer.writeLine(":") + writer.withNestedLevel { renderCodeBlocks(switchCase.body) } + } + + /// Renders the specified switch expression. + func renderSwitch(_ switchDesc: SwitchDescription) { + writer.writeLine("switch ") + writer.nextLineAppendsToLastLine() + renderExpression(switchDesc.switchedExpression) + writer.nextLineAppendsToLastLine() + writer.writeLine(" {") + for caseDesc in switchDesc.cases { renderSwitchCase(caseDesc) } + writer.writeLine("}") + } + + /// Renders the specified if statement. + func renderIf(_ ifDesc: IfStatementDescription) { + let ifBranch = ifDesc.ifBranch + writer.writeLine("if ") + writer.nextLineAppendsToLastLine() + renderExpression(ifBranch.condition) + writer.nextLineAppendsToLastLine() + writer.writeLine(" {") + writer.withNestedLevel { renderCodeBlocks(ifBranch.body) } + writer.writeLine("}") + for branch in ifDesc.elseIfBranches { + writer.nextLineAppendsToLastLine() + writer.writeLine(" else if ") + writer.nextLineAppendsToLastLine() + renderExpression(branch.condition) + writer.nextLineAppendsToLastLine() + writer.writeLine(" {") + writer.withNestedLevel { renderCodeBlocks(branch.body) } + writer.writeLine("}") + } + if let elseBody = ifDesc.elseBody { + writer.nextLineAppendsToLastLine() + writer.writeLine(" else {") + writer.withNestedLevel { renderCodeBlocks(elseBody) } + writer.writeLine("}") + } + } + + /// Renders the specified switch expression. + func renderDoStatement(_ description: DoStatementDescription) { + writer.writeLine("do {") + writer.withNestedLevel { renderCodeBlocks(description.doStatement) } + if let catchBody = description.catchBody { + writer.writeLine("} catch {") + if !catchBody.isEmpty { + writer.withNestedLevel { renderCodeBlocks(catchBody) } + } else { + writer.nextLineAppendsToLastLine() + } + } + writer.writeLine("}") + } + + /// Renders the specified value binding expression. + func renderValueBinding(_ valueBinding: ValueBindingDescription) { + writer.writeLine("\(renderedBindingKind(valueBinding.kind)) ") + writer.nextLineAppendsToLastLine() + renderFunctionCall(valueBinding.value) + } + + /// Renders the specified keyword. + func renderedKeywordKind(_ kind: KeywordKind) -> String { + switch kind { + case .return: return "return" + case .try(hasPostfixQuestionMark: let hasPostfixQuestionMark): + return "try\(hasPostfixQuestionMark ? "?" : "")" + case .await: return "await" + case .throw: return "throw" + case .yield: return "yield" + } + } + + /// Renders the specified unary keyword expression. + func renderUnaryKeywordExpression(_ expression: UnaryKeywordDescription) { + writer.writeLine(renderedKeywordKind(expression.kind)) + guard let expr = expression.expression else { return } + writer.nextLineAppendsToLastLine() + writer.writeLine(" ") + writer.nextLineAppendsToLastLine() + renderExpression(expr) + } + + /// Renders the specified closure invocation. + func renderClosureInvocation(_ invocation: ClosureInvocationDescription) { + writer.writeLine("{") + if !invocation.argumentNames.isEmpty { + writer.nextLineAppendsToLastLine() + writer.writeLine(" \(invocation.argumentNames.joined(separator: ", ")) in") + } + if let body = invocation.body { writer.withNestedLevel { renderCodeBlocks(body) } } + writer.writeLine("}") + } + + /// Renders the specified binary operator. + func renderedBinaryOperator(_ op: BinaryOperator) -> String { op.rawValue } + + /// Renders the specified binary operation. + func renderBinaryOperation(_ operation: BinaryOperationDescription) { + renderExpression(operation.left) + writer.nextLineAppendsToLastLine() + writer.writeLine(" \(renderedBinaryOperator(operation.operation)) ") + writer.nextLineAppendsToLastLine() + renderExpression(operation.right) + } + + /// Renders the specified inout expression. + func renderInOutDescription(_ description: InOutDescription) { + writer.writeLine("&") + writer.nextLineAppendsToLastLine() + renderExpression(description.referencedExpr) + } + + /// Renders the specified optional chaining expression. + func renderOptionalChainingDescription(_ description: OptionalChainingDescription) { + renderExpression(description.referencedExpr) + writer.nextLineAppendsToLastLine() + writer.writeLine("?") + } + + /// Renders the specified tuple expression. + func renderTupleDescription(_ description: TupleDescription) { + writer.writeLine("(") + writer.nextLineAppendsToLastLine() + let members = description.members + for (member, isLast) in members.enumeratedWithLastMarker() { + renderExpression(member) + if !isLast { + writer.nextLineAppendsToLastLine() + writer.writeLine(", ") + } + writer.nextLineAppendsToLastLine() + } + writer.writeLine(")") + } + + /// Renders the specified expression. + func renderExpression(_ expression: Expression) { + switch expression { + case .literal(let literalDescription): renderLiteral(literalDescription) + case .identifier(let identifierDescription): + writer.writeLine(renderedIdentifier(identifierDescription)) + case .memberAccess(let memberAccessDescription): renderMemberAccess(memberAccessDescription) + case .functionCall(let functionCallDescription): renderFunctionCall(functionCallDescription) + case .assignment(let assignment): renderAssignment(assignment) + case .switch(let switchDesc): renderSwitch(switchDesc) + case .ifStatement(let ifDesc): renderIf(ifDesc) + case .doStatement(let doStmt): renderDoStatement(doStmt) + case .valueBinding(let valueBinding): renderValueBinding(valueBinding) + case .unaryKeyword(let unaryKeyword): renderUnaryKeywordExpression(unaryKeyword) + case .closureInvocation(let closureInvocation): renderClosureInvocation(closureInvocation) + case .binaryOperation(let binaryOperation): renderBinaryOperation(binaryOperation) + case .inOut(let inOut): renderInOutDescription(inOut) + case .optionalChaining(let optionalChaining): + renderOptionalChainingDescription(optionalChaining) + case .tuple(let tuple): renderTupleDescription(tuple) + } + } + + /// Renders the specified literal expression. + func renderLiteral(_ literal: LiteralDescription) { + func write(_ string: String) { writer.writeLine(string) } + switch literal { + case let .string(string): + // Use a raw literal if the string contains a quote/backslash. + if string.contains("\"") || string.contains("\\") { + write("#\"\(string)\"#") + } else { + write("\"\(string)\"") + } + case let .int(int): write("\(int)") + case let .bool(bool): write(bool ? "true" : "false") + case .nil: write("nil") + case .array(let items): + writer.writeLine("[") + if !items.isEmpty { + writer.withNestedLevel { + for (item, isLast) in items.enumeratedWithLastMarker() { + renderExpression(item) + if !isLast { + writer.nextLineAppendsToLastLine() + writer.writeLine(",") + } + } + } + } else { + writer.nextLineAppendsToLastLine() + } + writer.writeLine("]") + } + } + + /// Renders the specified where clause requirement. + func renderedWhereClauseRequirement(_ requirement: WhereClauseRequirement) -> String { + switch requirement { + case .conformance(let left, let right): return "\(left): \(right)" + } + } + + /// Renders the specified where clause. + func renderedWhereClause(_ clause: WhereClause) -> String { + let renderedRequirements = clause.requirements.map(renderedWhereClauseRequirement) + return "where \(renderedRequirements.joined(separator: ", "))" + } + + /// Renders the specified extension declaration. + func renderExtension(_ extensionDescription: ExtensionDescription) { + if let accessModifier = extensionDescription.accessModifier { + writer.writeLine(renderedAccessModifier(accessModifier) + " ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine("extension \(extensionDescription.onType)") + writer.nextLineAppendsToLastLine() + if !extensionDescription.conformances.isEmpty { + writer.writeLine(": \(extensionDescription.conformances.joined(separator: ", "))") + writer.nextLineAppendsToLastLine() + } + if let whereClause = extensionDescription.whereClause { + writer.writeLine(" " + renderedWhereClause(whereClause)) + writer.nextLineAppendsToLastLine() + } + writer.writeLine(" {") + for declaration in extensionDescription.declarations { + writer.withNestedLevel { renderDeclaration(declaration) } + } + writer.writeLine("}") + } + + /// Renders the specified type reference to an existing type. + func renderedExistingTypeDescription(_ type: ExistingTypeDescription) -> String { + switch type { + case .any(let existingTypeDescription): + return "any \(renderedExistingTypeDescription(existingTypeDescription))" + case .generic(let wrapper, let wrapped): + return + "\(renderedExistingTypeDescription(wrapper))<\(renderedExistingTypeDescription(wrapped))>" + case .optional(let existingTypeDescription): + return "\(renderedExistingTypeDescription(existingTypeDescription))?" + case .member(let components): return components.joined(separator: ".") + case .array(let existingTypeDescription): + return "[\(renderedExistingTypeDescription(existingTypeDescription))]" + case .dictionaryValue(let existingTypeDescription): + return "[String: \(renderedExistingTypeDescription(existingTypeDescription))]" + } + } + + /// Renders the specified typealias declaration. + func renderTypealias(_ alias: TypealiasDescription) { + var words: [String] = [] + if let accessModifier = alias.accessModifier { + words.append(renderedAccessModifier(accessModifier)) + } + words.append(contentsOf: [ + "typealias", alias.name, "=", renderedExistingTypeDescription(alias.existingType), + ]) + writer.writeLine(words.joinedWords()) + } + + /// Renders the specified binding kind. + func renderedBindingKind(_ kind: BindingKind) -> String { + switch kind { + case .var: return "var" + case .let: return "let" + } + } + + /// Renders the specified variable declaration. + func renderVariable(_ variable: VariableDescription) { + do { + if let accessModifier = variable.accessModifier { + writer.writeLine(renderedAccessModifier(accessModifier) + " ") + writer.nextLineAppendsToLastLine() + } + if variable.isStatic { + writer.writeLine("static ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine(renderedBindingKind(variable.kind) + " ") + writer.nextLineAppendsToLastLine() + renderExpression(variable.left) + if let type = variable.type { + writer.nextLineAppendsToLastLine() + writer.writeLine(": \(renderedExistingTypeDescription(type))") + } + } + + if let right = variable.right { + writer.nextLineAppendsToLastLine() + writer.writeLine(" = ") + writer.nextLineAppendsToLastLine() + renderExpression(right) + } + + if let body = variable.getter { + writer.nextLineAppendsToLastLine() + writer.writeLine(" {") + writer.withNestedLevel { + let hasExplicitGetter = + !variable.getterEffects.isEmpty || variable.setter != nil || variable.modify != nil + if hasExplicitGetter { + let keywords = variable.getterEffects.map(renderedFunctionKeyword).joined(separator: " ") + let line = "get \(keywords) {" + writer.writeLine(line) + writer.push() + } + renderCodeBlocks(body) + if hasExplicitGetter { + writer.pop() + writer.writeLine("}") + } + if let modify = variable.modify { + writer.writeLine("_modify {") + writer.withNestedLevel { renderCodeBlocks(modify) } + writer.writeLine("}") + } + if let setter = variable.setter { + writer.writeLine("set {") + writer.withNestedLevel { renderCodeBlocks(setter) } + writer.writeLine("}") + } + } + writer.writeLine("}") + } + } + + /// Renders the specified struct declaration. + func renderStruct(_ structDesc: StructDescription) { + if let accessModifier = structDesc.accessModifier { + writer.writeLine(renderedAccessModifier(accessModifier) + " ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine("struct \(structDesc.name)") + writer.nextLineAppendsToLastLine() + if !structDesc.conformances.isEmpty { + writer.writeLine(": \(structDesc.conformances.joined(separator: ", "))") + writer.nextLineAppendsToLastLine() + } + writer.writeLine(" {") + if !structDesc.members.isEmpty { + writer.withNestedLevel { for member in structDesc.members { renderDeclaration(member) } } + } else { + writer.nextLineAppendsToLastLine() + } + writer.writeLine("}") + } + + /// Renders the specified protocol declaration. + func renderProtocol(_ protocolDesc: ProtocolDescription) { + if let accessModifier = protocolDesc.accessModifier { + writer.writeLine("\(renderedAccessModifier(accessModifier)) ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine("protocol \(protocolDesc.name)") + writer.nextLineAppendsToLastLine() + if !protocolDesc.conformances.isEmpty { + let conformances = protocolDesc.conformances.joined(separator: ", ") + writer.writeLine(": \(conformances)") + writer.nextLineAppendsToLastLine() + } + writer.writeLine(" {") + if !protocolDesc.members.isEmpty { + writer.withNestedLevel { for member in protocolDesc.members { renderDeclaration(member) } } + } else { + writer.nextLineAppendsToLastLine() + } + writer.writeLine("}") + } + + /// Renders the specified enum declaration. + func renderEnum(_ enumDesc: EnumDescription) { + if enumDesc.isFrozen { + writer.writeLine("@frozen ") + writer.nextLineAppendsToLastLine() + } + if let accessModifier = enumDesc.accessModifier { + writer.writeLine("\(renderedAccessModifier(accessModifier)) ") + writer.nextLineAppendsToLastLine() + } + if enumDesc.isIndirect { + writer.writeLine("indirect ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine("enum \(enumDesc.name)") + writer.nextLineAppendsToLastLine() + if !enumDesc.conformances.isEmpty { + writer.writeLine(": \(enumDesc.conformances.joined(separator: ", "))") + writer.nextLineAppendsToLastLine() + } + writer.writeLine(" {") + if !enumDesc.members.isEmpty { + writer.withNestedLevel { for member in enumDesc.members { renderDeclaration(member) } } + } else { + writer.nextLineAppendsToLastLine() + } + writer.writeLine("}") + } + + /// Renders the specified enum case associated value. + func renderedEnumCaseAssociatedValue(_ value: EnumCaseAssociatedValueDescription) -> String { + var words: [String] = [] + if let label = value.label { words.append(label + ":") } + words.append(renderedExistingTypeDescription(value.type)) + return words.joinedWords() + } + + /// Renders the specified enum case declaration. + func renderEnumCase(_ enumCase: EnumCaseDescription) { + writer.writeLine("case \(enumCase.name)") + switch enumCase.kind { + case .nameOnly: break + case .nameWithRawValue(let rawValue): + writer.nextLineAppendsToLastLine() + writer.writeLine(" = ") + writer.nextLineAppendsToLastLine() + renderLiteral(rawValue) + case .nameWithAssociatedValues(let values): + if values.isEmpty { break } + let associatedValues = values.map(renderedEnumCaseAssociatedValue).joined(separator: ", ") + writer.nextLineAppendsToLastLine() + writer.writeLine("(\(associatedValues))") + } + } + + /// Renders the specified declaration. + func renderDeclaration(_ declaration: Declaration) { + switch declaration { + case let .commentable(comment, nestedDeclaration): + renderCommentableDeclaration(comment: comment, declaration: nestedDeclaration) + case let .deprecated(deprecation, nestedDeclaration): + renderDeprecatedDeclaration(deprecation: deprecation, declaration: nestedDeclaration) + case .variable(let variableDescription): renderVariable(variableDescription) + case .extension(let extensionDescription): renderExtension(extensionDescription) + case .struct(let structDescription): renderStruct(structDescription) + case .protocol(let protocolDescription): renderProtocol(protocolDescription) + case .enum(let enumDescription): renderEnum(enumDescription) + case .typealias(let typealiasDescription): renderTypealias(typealiasDescription) + case .function(let functionDescription): renderFunction(functionDescription) + case .enumCase(let enumCase): renderEnumCase(enumCase) + } + } + + /// Renders the specified function kind. + func renderedFunctionKind(_ functionKind: FunctionKind) -> String { + switch functionKind { + case .initializer(let isFailable): return "init\(isFailable ? "?" : "")" + case .function(let name, let isStatic): return (isStatic ? "static " : "") + "func \(name)" + } + } + + /// Renders the specified function keyword. + func renderedFunctionKeyword(_ keyword: FunctionKeyword) -> String { + switch keyword { + case .throws: return "throws" + case .async: return "async" + } + } + + /// Renders the specified function signature. + func renderFunctionSignature(_ signature: FunctionSignatureDescription) { + do { + if let accessModifier = signature.accessModifier { + writer.writeLine(renderedAccessModifier(accessModifier) + " ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine(renderedFunctionKind(signature.kind) + "(") + let parameters = signature.parameters + let separateLines = parameters.count > 1 + if separateLines { + writer.withNestedLevel { + for (parameter, isLast) in signature.parameters.enumeratedWithLastMarker() { + renderParameter(parameter) + if !isLast { + writer.nextLineAppendsToLastLine() + writer.writeLine(",") + } + } + } + } else { + writer.nextLineAppendsToLastLine() + if let parameter = parameters.first { renderParameter(parameter) } + writer.nextLineAppendsToLastLine() + } + writer.writeLine(")") + } + + do { + let keywords = signature.keywords + if !keywords.isEmpty { + for keyword in keywords { + writer.nextLineAppendsToLastLine() + writer.writeLine(" " + renderedFunctionKeyword(keyword)) + } + } + } + + if let returnType = signature.returnType { + writer.nextLineAppendsToLastLine() + writer.writeLine(" -> ") + writer.nextLineAppendsToLastLine() + renderExpression(returnType) + } + } + + /// Renders the specified function declaration. + func renderFunction(_ functionDescription: FunctionDescription) { + renderFunctionSignature(functionDescription.signature) + guard let body = functionDescription.body else { return } + writer.nextLineAppendsToLastLine() + writer.writeLine(" {") + if !body.isEmpty { + writer.withNestedLevel { renderCodeBlocks(body) } + } else { + writer.nextLineAppendsToLastLine() + } + writer.writeLine("}") + } + + /// Renders the specified parameter declaration. + func renderParameter(_ parameterDescription: ParameterDescription) { + if let label = parameterDescription.label { + writer.writeLine(label) + } else { + writer.writeLine("_") + } + writer.nextLineAppendsToLastLine() + if let name = parameterDescription.name, name != parameterDescription.label { + // If the label and name are the same value, don't repeat it. + writer.writeLine(" ") + writer.nextLineAppendsToLastLine() + writer.writeLine(name) + writer.nextLineAppendsToLastLine() + } + writer.writeLine(": ") + writer.nextLineAppendsToLastLine() + writer.writeLine(renderedExistingTypeDescription(parameterDescription.type)) + if let defaultValue = parameterDescription.defaultValue { + writer.nextLineAppendsToLastLine() + writer.writeLine(" = ") + writer.nextLineAppendsToLastLine() + renderExpression(defaultValue) + } + } + + /// Renders the specified declaration with a comment. + func renderCommentableDeclaration(comment: Comment?, declaration: Declaration) { + if let comment { renderComment(comment) } + renderDeclaration(declaration) + } + + /// Renders the specified declaration with a deprecation annotation. + func renderDeprecatedDeclaration(deprecation: DeprecationDescription, declaration: Declaration) { + renderDeprecation(deprecation) + renderDeclaration(declaration) + } + + func renderDeprecation(_ deprecation: DeprecationDescription) { + let things: [String] = [ + "*", "deprecated", deprecation.message.map { "message: \"\($0)\"" }, + deprecation.renamed.map { "renamed: \"\($0)\"" }, + ] + .compactMap({ $0 }) + let line = "@available(\(things.joined(separator: ", ")))" + writer.writeLine(line) + } + + /// Renders the specified code block item. + func renderCodeBlockItem(_ description: CodeBlockItem) { + switch description { + case .declaration(let declaration): renderDeclaration(declaration) + case .expression(let expression): renderExpression(expression) + } + } + + /// Renders the specified code block. + func renderCodeBlock(_ description: CodeBlock) { + if let comment = description.comment { renderComment(comment) } + let item = description.item + renderCodeBlockItem(item) + } + + /// Renders the specified code blocks. + func renderCodeBlocks(_ blocks: [CodeBlock]) { blocks.forEach(renderCodeBlock) } +} + +extension Array { + + /// Returns a collection of tuples, where the first element is + /// the collection element and the second is a Boolean value indicating + /// whether it is the last element in the collection. + /// - Returns: A collection of tuples. + fileprivate func enumeratedWithLastMarker() -> [(Element, isLast: Bool)] { + let count = count + return enumerated().map { index, element in (element, index == count - 1) } + } +} + +extension Array where Element == String { + /// Returns a string where the elements of the array are joined + /// by a space character. + /// - Returns: A string with the elements of the array joined by space characters. + fileprivate func joinedWords() -> String { joined(separator: " ") } +} + +extension String { + + /// Returns an array of strings, where each string represents one line + /// in the current string. + /// - Returns: An array of strings, each representing one line in the original string. + fileprivate func asLines() -> [String] { + split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).map(String.init) + } + + /// Returns a new string where the provided closure transforms each line. + /// The closure takes a string representing one line as a parameter. + /// - Parameter work: The closure that transforms each line. + /// - Returns: A new string where each line has been transformed using the given closure. + fileprivate func transformingLines(_ work: (String) -> String) -> [String] { asLines().map(work) } +} + +extension TextBasedRenderer { + + /// Returns the provided expression rendered as a string. + /// - Parameter expression: The expression. + /// - Returns: The string representation of the expression. + static func renderedExpressionAsString(_ expression: Expression) -> String { + let renderer = TextBasedRenderer.default + renderer.renderExpression(expression) + return renderer.renderedContents() + } +} diff --git a/Sources/GRPCCodeGen/Internal/TypeName.swift b/Sources/GRPCCodeGen/Internal/TypeName.swift index 4340baa23..0152de6a0 100644 --- a/Sources/GRPCCodeGen/Internal/TypeName.swift +++ b/Sources/GRPCCodeGen/Internal/TypeName.swift @@ -35,7 +35,7 @@ import Foundation /// to a type. struct TypeName: Hashable { /// A list of components that make up the type name. - private let components: [String] + internal let components: [String] /// Creates a new type name with the specified list of components. /// - Parameter components: A list of components for the type. @@ -78,7 +78,7 @@ struct TypeName: Hashable { extension TypeName: CustomStringConvertible { var description: String { - return fullyQualifiedSwiftName + return fullyQualifiedName } } diff --git a/Tests/GRPCCodeGenTests/Internal/Renderer/TextBasedRendererTests.swift b/Tests/GRPCCodeGenTests/Internal/Renderer/TextBasedRendererTests.swift new file mode 100644 index 000000000..bbe99de1c --- /dev/null +++ b/Tests/GRPCCodeGenTests/Internal/Renderer/TextBasedRendererTests.swift @@ -0,0 +1,781 @@ +/* + * Copyright 2023, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import XCTest + +@testable import GRPCCodeGen + +final class Test_TextBasedRenderer: XCTestCase { + + func testComment() throws { + try _test( + .inline( + #""" + Generated by foo + + Also, bar + """# + ), + renderedBy: TextBasedRenderer.renderComment, + rendersAs: #""" + // Generated by foo + // + // Also, bar + """# + ) + try _test( + .doc( + #""" + Generated by foo + + Also, bar + """# + ), + renderedBy: TextBasedRenderer.renderComment, + rendersAs: #""" + /// Generated by foo + /// + /// Also, bar + """# + ) + try _test( + .mark("Lorem ipsum", sectionBreak: false), + renderedBy: TextBasedRenderer.renderComment, + rendersAs: #""" + // MARK: Lorem ipsum + """# + ) + try _test( + .mark("Lorem ipsum", sectionBreak: true), + renderedBy: TextBasedRenderer.renderComment, + rendersAs: #""" + // MARK: - Lorem ipsum + """# + ) + try _test( + .inline( + """ + Generated by foo\r\nAlso, bar + """ + ), + renderedBy: TextBasedRenderer.renderComment, + rendersAs: #""" + // Generated by foo + // Also, bar + """# + ) + } + + func testImports() throws { + try _test(nil, renderedBy: TextBasedRenderer.renderImports, rendersAs: "") + try _test( + [ImportDescription(moduleName: "Foo"), ImportDescription(moduleName: "Bar")], + renderedBy: TextBasedRenderer.renderImports, + rendersAs: #""" + import Foo + import Bar + """# + ) + try _test( + [ImportDescription(moduleName: "Foo", spi: "Secret")], + renderedBy: TextBasedRenderer.renderImports, + rendersAs: #""" + @_spi(Secret) import Foo + """# + ) + try _test( + [ImportDescription(moduleName: "Foo", preconcurrency: .onOS(["Bar", "Baz"]))], + renderedBy: TextBasedRenderer.renderImports, + rendersAs: #""" + #if os(Bar) || os(Baz) + @preconcurrency import Foo + #else + import Foo + #endif + """# + ) + try _test( + [ + ImportDescription(moduleName: "Foo", preconcurrency: .always), + ImportDescription(moduleName: "Bar", spi: "Secret", preconcurrency: .always), + ], + renderedBy: TextBasedRenderer.renderImports, + rendersAs: #""" + @preconcurrency import Foo + @preconcurrency @_spi(Secret) import Bar + """# + ) + } + + func testAccessModifiers() throws { + try _test( + .public, + renderedBy: TextBasedRenderer.renderedAccessModifier, + rendersAs: #""" + public + """# + ) + try _test( + .internal, + renderedBy: TextBasedRenderer.renderedAccessModifier, + rendersAs: #""" + internal + """# + ) + try _test( + .fileprivate, + renderedBy: TextBasedRenderer.renderedAccessModifier, + rendersAs: #""" + fileprivate + """# + ) + try _test( + .private, + renderedBy: TextBasedRenderer.renderedAccessModifier, + rendersAs: #""" + private + """# + ) + } + + func testLiterals() throws { + try _test( + .string("hi"), + renderedBy: TextBasedRenderer.renderLiteral, + rendersAs: #""" + "hi" + """# + ) + try _test( + .string("this string: \"foo\""), + renderedBy: TextBasedRenderer.renderLiteral, + rendersAs: #""" + #"this string: "foo""# + """# + ) + try _test( + .nil, + renderedBy: TextBasedRenderer.renderLiteral, + rendersAs: #""" + nil + """# + ) + try _test( + .array([]), + renderedBy: TextBasedRenderer.renderLiteral, + rendersAs: #""" + [] + """# + ) + try _test( + .array([.literal(.nil)]), + renderedBy: TextBasedRenderer.renderLiteral, + rendersAs: #""" + [ + nil + ] + """# + ) + try _test( + .array([.literal(.nil), .literal(.nil)]), + renderedBy: TextBasedRenderer.renderLiteral, + rendersAs: #""" + [ + nil, + nil + ] + """# + ) + } + + func testExpression() throws { + try _test( + .literal(.nil), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + nil + """# + ) + try _test( + .identifierPattern("foo"), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + foo + """# + ) + try _test( + .memberAccess(.init(left: .identifierPattern("foo"), right: "bar")), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + foo.bar + """# + ) + try _test( + .functionCall( + .init( + calledExpression: .identifierPattern("callee"), + arguments: [.init(label: nil, expression: .identifierPattern("foo"))] + ) + ), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + callee(foo) + """# + ) + } + + func testDeclaration() throws { + try _test( + .variable(kind: .let, left: "foo"), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + let foo + """# + ) + try _test( + .extension(.init(onType: "String", declarations: [])), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + extension String { + } + """# + ) + try _test( + .struct(.init(name: "Foo")), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + struct Foo {} + """# + ) + try _test( + .protocol(.init(name: "Foo")), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + protocol Foo {} + """# + ) + try _test( + .enum(.init(name: "Foo")), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + enum Foo {} + """# + ) + try _test( + .typealias(.init(name: "foo", existingType: .member(["Foo", "Bar"]))), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + typealias foo = Foo.Bar + """# + ) + try _test( + .function(FunctionDescription.init(kind: .function(name: "foo"), body: [])), + renderedBy: TextBasedRenderer.renderDeclaration, + rendersAs: #""" + func foo() {} + """# + ) + } + + func testFunctionKind() throws { + try _test( + .initializer, + renderedBy: TextBasedRenderer.renderedFunctionKind, + rendersAs: #""" + init + """# + ) + try _test( + .function(name: "funky"), + renderedBy: TextBasedRenderer.renderedFunctionKind, + rendersAs: #""" + func funky + """# + ) + try _test( + .function(name: "funky", isStatic: true), + renderedBy: TextBasedRenderer.renderedFunctionKind, + rendersAs: #""" + static func funky + """# + ) + } + + func testFunctionKeyword() throws { + try _test( + .throws, + renderedBy: TextBasedRenderer.renderedFunctionKeyword, + rendersAs: #""" + throws + """# + ) + try _test( + .async, + renderedBy: TextBasedRenderer.renderedFunctionKeyword, + rendersAs: #""" + async + """# + ) + } + + func testParameter() throws { + try _test( + .init(label: "l", name: "n", type: .member("T"), defaultValue: .literal(.nil)), + renderedBy: TextBasedRenderer.renderParameter, + rendersAs: #""" + l n: T = nil + """# + ) + try _test( + .init(label: nil, name: "n", type: .member("T"), defaultValue: .literal(.nil)), + renderedBy: TextBasedRenderer.renderParameter, + rendersAs: #""" + _ n: T = nil + """# + ) + try _test( + .init(label: "l", name: nil, type: .member("T"), defaultValue: .literal(.nil)), + renderedBy: TextBasedRenderer.renderParameter, + rendersAs: #""" + l: T = nil + """# + ) + try _test( + .init(label: nil, name: nil, type: .member("T"), defaultValue: .literal(.nil)), + renderedBy: TextBasedRenderer.renderParameter, + rendersAs: #""" + _: T = nil + """# + ) + try _test( + .init(label: nil, name: nil, type: .member("T"), defaultValue: nil), + renderedBy: TextBasedRenderer.renderParameter, + rendersAs: #""" + _: T + """# + ) + } + + func testFunction() throws { + try _test( + .init(accessModifier: .public, kind: .function(name: "f"), parameters: [], body: []), + renderedBy: TextBasedRenderer.renderFunction, + rendersAs: #""" + public func f() {} + """# + ) + try _test( + .init( + accessModifier: .public, + kind: .function(name: "f"), + parameters: [.init(label: "a", name: "b", type: .member("C"), defaultValue: nil)], + body: [] + ), + renderedBy: TextBasedRenderer.renderFunction, + rendersAs: #""" + public func f(a b: C) {} + """# + ) + try _test( + .init( + accessModifier: .public, + kind: .function(name: "f"), + parameters: [ + .init(label: "a", name: "b", type: .member("C"), defaultValue: nil), + .init(label: nil, name: "d", type: .member("E"), defaultValue: .literal(.string("f"))), + ], + body: [] + ), + renderedBy: TextBasedRenderer.renderFunction, + rendersAs: #""" + public func f( + a b: C, + _ d: E = "f" + ) {} + """# + ) + try _test( + .init( + kind: .function(name: "f"), + parameters: [], + keywords: [.async, .throws], + returnType: .identifierType(TypeName.string) + ), + renderedBy: TextBasedRenderer.renderFunction, + rendersAs: #""" + func f() async throws -> Swift.String + """# + ) + } + + func testIdentifiers() throws { + try _test( + .pattern("foo"), + renderedBy: TextBasedRenderer.renderedIdentifier, + rendersAs: #""" + foo + """# + ) + } + + func testMemberAccess() throws { + try _test( + .init(left: .identifierPattern("foo"), right: "bar"), + renderedBy: TextBasedRenderer.renderMemberAccess, + rendersAs: #""" + foo.bar + """# + ) + try _test( + .init(left: nil, right: "bar"), + renderedBy: TextBasedRenderer.renderMemberAccess, + rendersAs: #""" + .bar + """# + ) + } + + func testFunctionCallArgument() throws { + try _test( + .init(label: "foo", expression: .identifierPattern("bar")), + renderedBy: TextBasedRenderer.renderFunctionCallArgument, + rendersAs: #""" + foo: bar + """# + ) + try _test( + .init(label: nil, expression: .identifierPattern("bar")), + renderedBy: TextBasedRenderer.renderFunctionCallArgument, + rendersAs: #""" + bar + """# + ) + } + + func testFunctionCall() throws { + try _test( + .functionCall(.init(calledExpression: .identifierPattern("callee"))), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + callee() + """# + ) + try _test( + .functionCall( + .init( + calledExpression: .identifierPattern("callee"), + arguments: [.init(label: "foo", expression: .identifierPattern("bar"))] + ) + ), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + callee(foo: bar) + """# + ) + try _test( + .functionCall( + .init( + calledExpression: .identifierPattern("callee"), + arguments: [ + .init(label: "foo", expression: .identifierPattern("bar")), + .init(label: "baz", expression: .identifierPattern("boo")), + ] + ) + ), + renderedBy: TextBasedRenderer.renderExpression, + rendersAs: #""" + callee( + foo: bar, + baz: boo + ) + """# + ) + } + + func testExtension() throws { + try _test( + .init( + accessModifier: .public, + onType: "Info", + declarations: [.variable(kind: .let, left: "foo", type: .member("Int"))] + ), + renderedBy: TextBasedRenderer.renderExtension, + rendersAs: #""" + public extension Info { + let foo: Int + } + """# + ) + } + + func testDeprecation() throws { + try _test( + .init(), + renderedBy: TextBasedRenderer.renderDeprecation, + rendersAs: #""" + @available(*, deprecated) + """# + ) + try _test( + .init(message: "some message"), + renderedBy: TextBasedRenderer.renderDeprecation, + rendersAs: #""" + @available(*, deprecated, message: "some message") + """# + ) + try _test( + .init(renamed: "newSymbol(param:)"), + renderedBy: TextBasedRenderer.renderDeprecation, + rendersAs: #""" + @available(*, deprecated, renamed: "newSymbol(param:)") + """# + ) + try _test( + .init(message: "some message", renamed: "newSymbol(param:)"), + renderedBy: TextBasedRenderer.renderDeprecation, + rendersAs: #""" + @available(*, deprecated, message: "some message", renamed: "newSymbol(param:)") + """# + ) + } + + func testBindingKind() throws { + try _test( + .var, + renderedBy: TextBasedRenderer.renderedBindingKind, + rendersAs: #""" + var + """# + ) + try _test( + .let, + renderedBy: TextBasedRenderer.renderedBindingKind, + rendersAs: #""" + let + """# + ) + } + + func testVariable() throws { + try _test( + .init( + accessModifier: .public, + isStatic: true, + kind: .let, + left: .identifierPattern("foo"), + type: .init(TypeName.string), + right: .literal(.string("bar")) + ), + renderedBy: TextBasedRenderer.renderVariable, + rendersAs: #""" + public static let foo: Swift.String = "bar" + """# + ) + try _test( + .init( + accessModifier: .internal, + isStatic: false, + kind: .var, + left: .identifierPattern("foo"), + type: nil, + right: nil + ), + renderedBy: TextBasedRenderer.renderVariable, + rendersAs: #""" + internal var foo + """# + ) + try _test( + .init( + kind: .var, + left: .identifierPattern("foo"), + type: .init(TypeName.int), + getter: [CodeBlock.expression(.literal(.int(42)))] + ), + renderedBy: TextBasedRenderer.renderVariable, + rendersAs: #""" + var foo: Swift.Int { + 42 + } + """# + ) + try _test( + .init( + kind: .var, + left: .identifierPattern("foo"), + type: .init(TypeName.int), + getter: [CodeBlock.expression(.literal(.int(42)))], + getterEffects: [.throws] + ), + renderedBy: TextBasedRenderer.renderVariable, + rendersAs: #""" + var foo: Swift.Int { + get throws { + 42 + } + } + """# + ) + } + + func testStruct() throws { + try _test( + .init(name: "Structy"), + renderedBy: TextBasedRenderer.renderStruct, + rendersAs: #""" + struct Structy {} + """# + ) + } + + func testProtocol() throws { + try _test( + .init(name: "Protocoly"), + renderedBy: TextBasedRenderer.renderProtocol, + rendersAs: #""" + protocol Protocoly {} + """# + ) + } + + func testEnum() throws { + try _test( + .init(name: "Enumy"), + renderedBy: TextBasedRenderer.renderEnum, + rendersAs: #""" + enum Enumy {} + """# + ) + } + + func testCodeBlockItem() throws { + try _test( + .declaration(.variable(kind: .let, left: "foo")), + renderedBy: TextBasedRenderer.renderCodeBlockItem, + rendersAs: #""" + let foo + """# + ) + try _test( + .expression(.literal(.nil)), + renderedBy: TextBasedRenderer.renderCodeBlockItem, + rendersAs: #""" + nil + """# + ) + } + + func testCodeBlock() throws { + try _test( + .init( + comment: .inline("- MARK: Section"), + item: .declaration(.variable(kind: .let, left: "foo")) + ), + renderedBy: TextBasedRenderer.renderCodeBlock, + rendersAs: #""" + // - MARK: Section + let foo + """# + ) + try _test( + .init(comment: nil, item: .declaration(.variable(kind: .let, left: "foo"))), + renderedBy: TextBasedRenderer.renderCodeBlock, + rendersAs: #""" + let foo + """# + ) + } + + func testTypealias() throws { + try _test( + .init(name: "inty", existingType: .member("Int")), + renderedBy: TextBasedRenderer.renderTypealias, + rendersAs: #""" + typealias inty = Int + """# + ) + try _test( + .init(accessModifier: .private, name: "inty", existingType: .member("Int")), + renderedBy: TextBasedRenderer.renderTypealias, + rendersAs: #""" + private typealias inty = Int + """# + ) + } + + func testFile() throws { + try _test( + .init( + topComment: .inline("hi"), + imports: [.init(moduleName: "Foo")], + codeBlocks: [.init(comment: nil, item: .declaration(.struct(.init(name: "Bar"))))] + ), + renderedBy: TextBasedRenderer.renderFile, + rendersAs: #""" + // hi + import Foo + struct Bar {} + + """# + ) + } +} + +extension Test_TextBasedRenderer { + + func _test( + _ input: Input, + renderedBy renderClosure: (TextBasedRenderer) -> ((Input) -> String), + rendersAs output: String, + file: StaticString = #file, + line: UInt = #line + ) throws { + let renderer = TextBasedRenderer.default + XCTAssertEqual(renderClosure(renderer)(input), output, file: file, line: line) + } + + func _test( + _ input: Input, + renderedBy renderClosure: (TextBasedRenderer) -> ((Input) -> Void), + rendersAs output: String, + file: StaticString = #file, + line: UInt = #line + ) throws { + try _test( + input, + renderedBy: { renderer in + let closure = renderClosure(renderer) + return { input in + closure(input) + return renderer.renderedContents() + } + }, + rendersAs: output + ) + } +}