From 7e52a1dd0100640f80092be42d4616924d4fd905 Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 2 Apr 2026 08:16:38 -0400 Subject: [PATCH 1/4] V1.0.0 beta.1 (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: Swift App Template v1.0.0-beta.1 (#11) - Copy and restructure MonthBar infrastructure into template layout - Replace all hardcoded values with `{{TEMPLATE_*}}` placeholder tokens - Add `Packages/TemplateGenerator` — Swift executable that generates app skeleton files using SyntaxKit - Add `Scripts/setup.sh` — interactive/automated setup with 19 token substitutions, Swift file generation, CI activation, full-stack mode, cleanup, and git reset - Add `README.md`, `README.template.md`, and `CLAUDE.template.md` template documentation - Move `workflows/app.yml` → `.github/app.workflow.yml` so GitHub skips parsing it until `setup.sh` activates it - Add `.github/workflows/validate-template.yml` — CI that confirms placeholder tokens haven't been replaced on the template repo - Replace placeholder-specific icon assets with a generic gradient placeholder - Sync `_reference/MonthBar` to latest upstream - Add `_reference/AtLeast` — iOS/watchOS companion app reference with multi-platform Fastlane and dynamic CI matrix - Extract `private_lane :setup_api_key` in Fastfile; add `sync_build_number`, `sync_last_release`, `upload_metadata`, `upload_privacy_details`, `submit_for_review` lanes - Add `pull_request` trigger, concurrency group, and dynamic matrix to CI workflow - Split `build-macos` into always-run and full-matrix jobs; gate Windows/Android on cross-platform condition - Add conditional server test jobs wrapped in `FULL_STACK_ONLY` markers for `setup.sh` processing - Bump actions: `checkout@v6`, `swift-build@v1.5.2`, `swift-coverage-action@v5`, `codecov-action@v6`, `mise-action@v4` - Prefix all Makefile `fastlane` calls with `mise exec --`; add screenshot targets - Expand CLAUDE.md Commands and Code Signing sections with new Fastlane lanes and make targets - Add automation guide TODO tracker linked to issues #28–#50 - Fix `bundle install` and git section in `setup.sh`; harden with `printf -v`, required-field validation, and `swift` availability check - Replace `keywords.txt` with lorem ipsum placeholder to force users to update before App Store submission - Bump Dockerfile base image from `swift:6.0-jammy` to `swift:6.3-noble` - Add `client` to `openapi-generator-config.yaml` generate list - Remove LFS exclusion section from `.gitattributes` --------- Co-authored-by: Claude Opus 4.6 --- Sources/SyntaxKit/Declarations/Class.swift | 24 +++- Sources/SyntaxKit/Declarations/Enum.swift | 5 +- .../SyntaxKit/Declarations/Extension.swift | 5 +- .../SyntaxKit/Declarations/IfCanImport.swift | 81 ++++++++++++ Sources/SyntaxKit/Declarations/Import.swift | 5 +- .../SyntaxKit/Declarations/Initializer.swift | 121 ++++++++++++++++++ Sources/SyntaxKit/Declarations/Protocol.swift | 5 +- Sources/SyntaxKit/Declarations/Struct.swift | 5 +- .../Functions/Function+Modifiers.swift | 15 +++ .../SyntaxKit/Functions/Function+Syntax.swift | 12 +- Sources/SyntaxKit/Functions/Function.swift | 1 + .../Utilities/AttributeArgument.swift | 51 ++++++++ .../Variables/Variable+Attributes.swift | 5 +- .../Unit/Attributes/AttributeTests.swift | 33 +++++ .../Unit/Declarations/ClassTests.swift | 45 +++++++ .../Unit/Declarations/ImportTests.swift | 31 +++++ .../Unit/Declarations/InitializerTests.swift | 74 +++++++++++ .../Unit/Functions/FunctionTests.swift | 45 +++++++ 18 files changed, 544 insertions(+), 19 deletions(-) create mode 100644 Sources/SyntaxKit/Declarations/IfCanImport.swift create mode 100644 Sources/SyntaxKit/Declarations/Initializer.swift create mode 100644 Sources/SyntaxKit/Utilities/AttributeArgument.swift create mode 100644 Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift create mode 100644 Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift diff --git a/Sources/SyntaxKit/Declarations/Class.swift b/Sources/SyntaxKit/Declarations/Class.swift index 2b6c03f..f20e577 100644 --- a/Sources/SyntaxKit/Declarations/Class.swift +++ b/Sources/SyntaxKit/Declarations/Class.swift @@ -37,6 +37,7 @@ public struct Class: CodeBlock, Sendable { private var genericParameters: [String] = [] private var isFinal: Bool = false private var attributes: [AttributeInfo] = [] + private var accessModifier: AccessModifier? /// The SwiftSyntax representation of this class declaration. public var syntax: any SyntaxProtocol { @@ -107,11 +108,16 @@ public struct Class: CodeBlock, Sendable { // Modifiers var modifiers: DeclModifierListSyntax = [] - if isFinal { + if let access = accessModifier { modifiers = DeclModifierListSyntax([ - DeclModifierSyntax(name: .keyword(.final, trailingTrivia: .space)) + DeclModifierSyntax(name: .keyword(access.keyword, trailingTrivia: .space)) ]) } + if isFinal { + modifiers = DeclModifierListSyntax( + modifiers + [DeclModifierSyntax(name: .keyword(.final, trailingTrivia: .space))] + ) + } return ClassDeclSyntax( attributes: attributeList, @@ -161,6 +167,15 @@ public struct Class: CodeBlock, Sendable { return copy } + /// Sets the access modifier for the class declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the class with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + /// Adds an attribute to the class declaration. /// - Parameters: /// - attribute: The attribute name (without the @ symbol). @@ -189,13 +204,13 @@ public struct Class: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -213,6 +228,7 @@ public struct Class: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } diff --git a/Sources/SyntaxKit/Declarations/Enum.swift b/Sources/SyntaxKit/Declarations/Enum.swift index d915fd1..a633a63 100644 --- a/Sources/SyntaxKit/Declarations/Enum.swift +++ b/Sources/SyntaxKit/Declarations/Enum.swift @@ -134,13 +134,13 @@ public struct Enum: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -158,6 +158,7 @@ public struct Enum: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Extension.swift b/Sources/SyntaxKit/Declarations/Extension.swift index fe87fb1..175d163 100644 --- a/Sources/SyntaxKit/Declarations/Extension.swift +++ b/Sources/SyntaxKit/Declarations/Extension.swift @@ -131,13 +131,13 @@ public struct Extension: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -155,6 +155,7 @@ public struct Extension: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/IfCanImport.swift b/Sources/SyntaxKit/Declarations/IfCanImport.swift new file mode 100644 index 0000000..0eecd77 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/IfCanImport.swift @@ -0,0 +1,81 @@ +// +// IfCanImport.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import SwiftSyntax + +/// A `#if canImport(Module)` … `#endif` conditional compilation block. +public struct IfCanImport: CodeBlock, Sendable { + private let moduleName: String + private let content: [any CodeBlock] + + /// Creates a `#if canImport(moduleName)` block wrapping the given content. + /// - Parameters: + /// - moduleName: The module name passed to `canImport`. + /// - content: The code blocks to emit when the module is available. + public init(_ moduleName: String, @CodeBlockBuilderResult _ content: () -> [any CodeBlock]) { + self.moduleName = moduleName + self.content = content() + } + + public var syntax: any SyntaxProtocol { + let condition = FunctionCallExprSyntax( + calledExpression: ExprSyntax(DeclReferenceExprSyntax( + baseName: .identifier("canImport") + )), + leftParen: .leftParenToken(), + arguments: LabeledExprListSyntax([ + LabeledExprSyntax( + expression: ExprSyntax(DeclReferenceExprSyntax( + baseName: .identifier(moduleName) + )) + ) + ]), + rightParen: .rightParenToken() + ) + + let items = CodeBlockItemListSyntax( + content.compactMap { block -> CodeBlockItemSyntax? in + CodeBlockItemSyntax.Item.create(from: block.syntax).map { + CodeBlockItemSyntax(item: $0, trailingTrivia: .newline) + } + } + ) + + let clause = IfConfigClauseSyntax( + poundKeyword: .poundIfToken(trailingTrivia: .space), + condition: ExprSyntax(condition).with(\.trailingTrivia, .newline), + elements: .statements(items) + ) + + return IfConfigDeclSyntax( + clauses: IfConfigClauseListSyntax([clause]), + poundEndif: .poundEndifToken(leadingTrivia: .newline) + ) + } +} diff --git a/Sources/SyntaxKit/Declarations/Import.swift b/Sources/SyntaxKit/Declarations/Import.swift index 6443de5..f452b58 100644 --- a/Sources/SyntaxKit/Declarations/Import.swift +++ b/Sources/SyntaxKit/Declarations/Import.swift @@ -101,13 +101,13 @@ public struct Import: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -125,6 +125,7 @@ public struct Import: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .space) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Initializer.swift b/Sources/SyntaxKit/Declarations/Initializer.swift new file mode 100644 index 0000000..8fd34cf --- /dev/null +++ b/Sources/SyntaxKit/Declarations/Initializer.swift @@ -0,0 +1,121 @@ +// +// Initializer.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import SwiftSyntax + +/// A Swift `init` declaration. +public struct Initializer: CodeBlock, Sendable { + private let body: [any CodeBlock] + private var accessModifier: AccessModifier? + private var isAsync: Bool = false + private var isThrowing: Bool = false + + /// The SwiftSyntax representation of this initializer declaration. + public var syntax: any SyntaxProtocol { + var modifiers: DeclModifierListSyntax = [] + if let access = accessModifier { + modifiers = DeclModifierListSyntax([ + DeclModifierSyntax(name: .keyword(access.keyword, trailingTrivia: .space)) + ]) + } + + var effectSpecifiers: FunctionEffectSpecifiersSyntax? + if isAsync || isThrowing { + effectSpecifiers = FunctionEffectSpecifiersSyntax( + asyncSpecifier: isAsync + ? .keyword(.async, leadingTrivia: .space, trailingTrivia: .space) + : nil, + throwsSpecifier: isThrowing ? .keyword(.throws, leadingTrivia: .space) : nil + ) + } + + let bodyBlock = CodeBlockSyntax( + leftBrace: .leftBraceToken(leadingTrivia: .space, trailingTrivia: .newline), + statements: CodeBlockItemListSyntax( + body.compactMap { item in + var codeBlockItem: CodeBlockItemSyntax? + if let decl = item.syntax.as(DeclSyntax.self) { + codeBlockItem = CodeBlockItemSyntax(item: .decl(decl)) + } else if let expr = item.syntax.as(ExprSyntax.self) { + codeBlockItem = CodeBlockItemSyntax(item: .expr(expr)) + } else if let stmt = item.syntax.as(StmtSyntax.self) { + codeBlockItem = CodeBlockItemSyntax(item: .stmt(stmt)) + } + return codeBlockItem?.with(\.trailingTrivia, .newline) + } + ), + rightBrace: .rightBraceToken(leadingTrivia: .newline) + ) + + return InitializerDeclSyntax( + modifiers: modifiers, + initKeyword: .keyword(.`init`), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax( + leftParen: .leftParenToken(), + parameters: FunctionParameterListSyntax([]), + rightParen: .rightParenToken() + ), + effectSpecifiers: effectSpecifiers + ), + body: bodyBlock + ) + } + + /// Creates an `init` declaration. + /// - Parameter content: A ``CodeBlockBuilder`` that provides the body of the initializer. + public init(@CodeBlockBuilderResult _ content: () throws -> [any CodeBlock]) rethrows { + self.body = try content() + } + + /// Sets the access modifier for the initializer declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the initializer with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + + /// Marks the initializer as `throws`. + /// - Returns: A copy of the initializer marked as `throws`. + public func throwing() -> Self { + var copy = self + copy.isThrowing = true + return copy + } + + /// Marks the initializer as `async`. + /// - Returns: A copy of the initializer marked as `async`. + public func async() -> Self { + var copy = self + copy.isAsync = true + return copy + } +} diff --git a/Sources/SyntaxKit/Declarations/Protocol.swift b/Sources/SyntaxKit/Declarations/Protocol.swift index db19c7a..dffec6c 100644 --- a/Sources/SyntaxKit/Declarations/Protocol.swift +++ b/Sources/SyntaxKit/Declarations/Protocol.swift @@ -136,13 +136,13 @@ public struct Protocol: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -160,6 +160,7 @@ public struct Protocol: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Struct.swift b/Sources/SyntaxKit/Declarations/Struct.swift index caaec04..ec9fdee 100644 --- a/Sources/SyntaxKit/Declarations/Struct.swift +++ b/Sources/SyntaxKit/Declarations/Struct.swift @@ -70,13 +70,13 @@ public struct Struct: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -94,6 +94,7 @@ public struct Struct: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Functions/Function+Modifiers.swift b/Sources/SyntaxKit/Functions/Function+Modifiers.swift index f50e427..2aa4c2a 100644 --- a/Sources/SyntaxKit/Functions/Function+Modifiers.swift +++ b/Sources/SyntaxKit/Functions/Function+Modifiers.swift @@ -46,6 +46,21 @@ extension Function { return copy } + /// Sets the access modifier for the function declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the function with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + + /// Marks the function as `throws` (alias for `.throws()` that avoids keyword escaping). + /// - Returns: A copy of the function marked as `throws`. + public func throwing() -> Self { + `throws`() + } + /// Adds an attribute to the function declaration. /// - Parameters: /// - attribute: The attribute name (without the @ symbol). diff --git a/Sources/SyntaxKit/Functions/Function+Syntax.swift b/Sources/SyntaxKit/Functions/Function+Syntax.swift index e766bea..7ab9bd4 100644 --- a/Sources/SyntaxKit/Functions/Function+Syntax.swift +++ b/Sources/SyntaxKit/Functions/Function+Syntax.swift @@ -83,9 +83,14 @@ extension Function { // Build modifiers var modifiers: DeclModifierListSyntax = [] + if let access = accessModifier { + modifiers = DeclModifierListSyntax([ + DeclModifierSyntax(name: .keyword(access.keyword, trailingTrivia: .space)) + ]) + } if isStatic { modifiers = DeclModifierListSyntax( - [ + modifiers + [ DeclModifierSyntax(name: .keyword(.static, trailingTrivia: .space)) ] ) @@ -133,13 +138,13 @@ extension Function { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with( \.trailingComma, @@ -160,6 +165,7 @@ extension Function { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } diff --git a/Sources/SyntaxKit/Functions/Function.swift b/Sources/SyntaxKit/Functions/Function.swift index dabae9f..9c9100b 100644 --- a/Sources/SyntaxKit/Functions/Function.swift +++ b/Sources/SyntaxKit/Functions/Function.swift @@ -39,6 +39,7 @@ public struct Function: CodeBlock { internal var isMutating: Bool = false internal var effect: Effect = .none internal var attributes: [AttributeInfo] = [] + internal var accessModifier: AccessModifier? /// Creates a `func` declaration. /// - Parameters: diff --git a/Sources/SyntaxKit/Utilities/AttributeArgument.swift b/Sources/SyntaxKit/Utilities/AttributeArgument.swift new file mode 100644 index 0000000..b940b45 --- /dev/null +++ b/Sources/SyntaxKit/Utilities/AttributeArgument.swift @@ -0,0 +1,51 @@ +// +// AttributeArgument.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +/// Builds an expression syntax node from an attribute argument string. +/// - Parameter argument: If surrounded by double-quotes, produces a string literal expression; +/// otherwise produces an identifier reference expression. +/// - Returns: An `ExprSyntax` representing the argument. +internal func buildAttributeArgumentExpr(from argument: String) -> ExprSyntax { + if argument.hasPrefix("\"") && argument.hasSuffix("\"") && argument.count >= 2 { + let content = String(argument.dropFirst().dropLast()) + return ExprSyntax( + StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: StringLiteralSegmentListSyntax([ + .stringSegment(StringSegmentSyntax(content: .stringSegment(content))) + ]), + closingQuote: .stringQuoteToken() + ) + ) + } else { + return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(argument))) + } +} diff --git a/Sources/SyntaxKit/Variables/Variable+Attributes.swift b/Sources/SyntaxKit/Variables/Variable+Attributes.swift index 6f4f954..bc2511d 100644 --- a/Sources/SyntaxKit/Variables/Variable+Attributes.swift +++ b/Sources/SyntaxKit/Variables/Variable+Attributes.swift @@ -54,6 +54,7 @@ extension Variable { arguments: attributeArgs.arguments, rightParen: attributeArgs.rightParen ) + .with(\.trailingTrivia, .space) ) } @@ -67,13 +68,13 @@ extension Variable { let rightParen: TokenSyntax = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + buildAttributeArgumentExpr(from: argument) } let argumentsSyntax = AttributeSyntax.Arguments.argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift index 1d9ce81..e7d5de4 100644 --- a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift @@ -181,6 +181,39 @@ import Testing #expect(generated.contains("func process")) } + @Test("Struct with quoted string attribute argument generates string literal, not identifier") + internal func testStructWithStringLiteralAttributeArgument() throws { + // Fix 3 regression: "\"App Model\"" must produce @Suite("App Model"), not @Suite(App Model) + let structDecl = Struct("AppModelTests") {} + .attribute("Suite", arguments: ["\"App Model\""]) + + let generated = structDecl.syntax.description + #expect(generated.contains("@Suite(\"App Model\")") || generated.contains("@Suite( \"App Model\")")) + #expect(!generated.contains("@Suite(App Model)")) + } + + @Test("Function with quoted string attribute argument generates string literal") + internal func testFunctionWithStringLiteralAttributeArgument() throws { + // Fix 3 regression: quoted argument must produce string literal token + let function = Function("initialCount") {} + .attribute("Test", arguments: ["\"Initial count is zero\""]) + + let generated = function.syntax.description + // The argument should be a string literal: @Test("Initial count is zero") + #expect(generated.contains("@Test(\"Initial count is zero\")") || generated.contains("@Test( \"Initial count is zero\")")) + } + + @Test("Struct with unquoted attribute argument generates identifier, not string literal") + internal func testStructWithIdentifierAttributeArgument() throws { + // Unquoted args should remain as identifier references + let structDecl = Struct("Serve") {} + .attribute("main") + + let generated = structDecl.syntax.description + #expect(generated.contains("@main")) + #expect(generated.contains("struct Serve")) + } + @Test("Parameter with attribute arguments generates correct syntax") internal func testParameterWithAttributeArguments() throws { let function = Function("validate") { diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift index ccd6573..aa3c813 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift @@ -162,6 +162,51 @@ internal struct ClassTests { #expect(normalizedGenerated == normalizedExpected) } + @Test internal func testPublicClass() { + let publicClass = Class("AppModel") {}.access(.public) + + let expected = """ + public class AppModel { + } + """ + + let normalizedGenerated = publicClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicFinalClass() { + let publicFinalClass = Class("AppModel") {} + .attribute("Observable") + .access(.public) + .final() + .inherits("Sendable") + + let generated = publicFinalClass.generateCode() + // Fix 2 regression: Class must support .access() + #expect(generated.contains("public")) + #expect(generated.contains("final")) + #expect(generated.contains("class AppModel")) + #expect(generated.contains("Sendable")) + // Access modifier must precede final + let publicRange = generated.range(of: "public")! + let finalRange = generated.range(of: "final")! + #expect(publicRange.lowerBound < finalRange.lowerBound) + } + + @Test internal func testInternalClass() { + let internalClass = Class("MyClass") {}.access(.internal) + + let expected = """ + internal class MyClass { + } + """ + + let normalizedGenerated = internalClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + @Test internal func testClassWithFunctions() { let classWithFunctions = Class("Calculator") { Function("add", returns: "Int") { diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift new file mode 100644 index 0000000..e6a9d66 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing + +@testable import SyntaxKit + +internal struct ImportTests { + @Test internal func testBasicImport() { + let importDecl = Import("Foundation") + + let generated = importDecl.generateCode() + #expect(generated.normalize() == "import Foundation") + } + + @Test internal func testImportWithTestableAttribute() { + let importDecl = Import("XCTest").attribute("testable") + + let generated = importDecl.generateCode() + // Fix 1 regression: must have a space between @testable and import + #expect(generated.contains("@testable import")) + #expect(!generated.contains("@testableimport")) + #expect(generated.contains("XCTest")) + } + + @Test internal func testImportWithGenericAttribute() { + let importDecl = Import("Foundation").attribute("_implementationOnly") + + let generated = importDecl.generateCode() + #expect(generated.contains("@_implementationOnly import")) + #expect(generated.contains("Foundation")) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift new file mode 100644 index 0000000..80f2f99 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing + +@testable import SyntaxKit + +internal struct InitializerTests { + @Test internal func testEmptyInit() { + let initDecl = Initializer {} + + let expected = """ + init() { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicInit() { + let initDecl = Initializer {}.access(.public) + + let expected = """ + public init() { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testThrowingInit() { + let initDecl = Initializer {}.throwing() + + let expected = """ + init() throws { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testAsyncInit() { + let initDecl = Initializer {}.async() + + let expected = """ + init() async { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicInitWithBody() { + let initDecl = Initializer { + Call("setup") + }.access(.internal) + + let expected = """ + internal init() { + setup() + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift b/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift index 4e5605a..dba1f78 100644 --- a/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift +++ b/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift @@ -87,6 +87,51 @@ internal struct FunctionTests { #expect(normalizedGenerated == normalizedExpected) } + @Test internal func testFunctionWithAccessModifier() throws { + let function = Function("run") { + Call("print") { + ParameterExp(unlabeled: Literal.string("hello")) + } + } + .access(.internal) + + let expected = """ + internal func run() { + print("hello") + } + """ + + let normalizedGenerated = function.syntax.description.normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testFunctionThrowingAlias() throws { + // .throwing() is an alias for .throws() that avoids keyword escaping at call sites + let function = Function("load") {} + .throwing() + + let generated = function.syntax.description + #expect(generated.contains("throws")) + #expect(generated.contains("func load")) + } + + @Test internal func testAsyncThrowingFunctionWithAccess() throws { + let function = Function("run") {} + .access(.internal) + .async() + .throwing() + + let expected = """ + internal func run() async throws { + } + """ + + let normalizedGenerated = function.syntax.description.normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + @Test internal func testMutatingFunction() throws { let function = Function( "updateValue", From 1fd6c9ec3804c3cb53f2457735c24a4d70fc5101 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 10 Jun 2026 21:19:46 -0400 Subject: [PATCH 2/4] Fix lint findings and lint.sh tooling after v0.0.5 rebase Resolve all swift-format and swiftlint findings in the V1.0.0 code and make Scripts/lint.sh self-recover when mise.toml is untrusted. - lint.sh: trust + install via mise before sourcing its env, so the mise-managed tools (swiftlint, periphery) land on PATH instead of silently no-op'ing on a fresh/untrusted mise.toml. - IfCanImport: add doc comment on `syntax`, order instance properties before the initializer, and flatten nested calls to satisfy multiline_arguments_brackets. - Class: split modifier methods into Class+Modifiers.swift (mirroring Function/Function+Modifiers.swift) to clear file_length; stored properties widened private -> internal to match. - ClassTests / AttributeTests: split into parent @Suite namespace enums with per-category child suites to clear file_length; replace force unwraps with try #require. swift build + 468 tests pass; lint.sh completes clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Scripts/lint.sh | 6 + .../Declarations/Class+Modifiers.swift | 76 +++++++ Sources/SyntaxKit/Declarations/Class.swift | 60 +---- .../SyntaxKit/Declarations/IfCanImport.swift | 37 ++-- .../SyntaxKit/Declarations/Initializer.swift | 6 +- .../Utilities/AttributeArgument.swift | 6 +- .../Attributes/AttributeTests+Arguments.swift | 137 ++++++++++++ .../Attributes/AttributeTests+Targets.swift | 137 ++++++++++++ .../Unit/Attributes/AttributeTests.swift | 204 +---------------- .../ClassTests+Declarations.swift | 159 ++++++++++++++ .../Declarations/ClassTests+Modifiers.swift | 114 ++++++++++ .../Unit/Declarations/ClassTests.swift | 205 +----------------- .../Unit/Declarations/ImportTests.swift | 29 +++ .../Unit/Declarations/InitializerTests.swift | 29 +++ 14 files changed, 721 insertions(+), 484 deletions(-) create mode 100644 Sources/SyntaxKit/Declarations/Class+Modifiers.swift create mode 100644 Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift create mode 100644 Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift create mode 100644 Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift create mode 100644 Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 5769c60..457bc4c 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -25,6 +25,12 @@ fi # Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then + # Trust the config (no-op if already trusted) so `mise env` doesn't silently + # emit nothing when mise.toml is new/untrusted, which would leave the + # mise-managed tools (swiftlint, periphery, ...) off PATH. + mise trust --quiet "$PACKAGE_DIR/mise.toml" >/dev/null 2>&1 || true + # Install any declared-but-missing tools so they resolve on PATH. + mise -C "$PACKAGE_DIR" install >/dev/null 2>&1 || true eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi diff --git a/Sources/SyntaxKit/Declarations/Class+Modifiers.swift b/Sources/SyntaxKit/Declarations/Class+Modifiers.swift new file mode 100644 index 0000000..a747667 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/Class+Modifiers.swift @@ -0,0 +1,76 @@ +// +// Class+Modifiers.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Class { + /// Sets the generic parameters for the class. + /// - Parameter generics: The list of generic parameter names. + /// - Returns: A copy of the class with the generic parameters set. + public func generic(_ generics: String...) -> Self { + var copy = self + copy.genericParameters = generics + return copy + } + + /// Sets the inheritance for the class. + /// - Parameter inheritance: The types to inherit from. + /// - Returns: A copy of the class with the inheritance set. + public func inherits(_ inheritance: String...) -> Self { + var copy = self + copy.inheritance = inheritance + return copy + } + + /// Marks the class declaration as `final`. + /// - Returns: A copy of the class marked as `final`. + public func final() -> Self { + var copy = self + copy.isFinal = true + return copy + } + + /// Sets the access modifier for the class declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the class with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + + /// Adds an attribute to the class declaration. + /// - Parameters: + /// - attribute: The attribute name (without the @ symbol). + /// - arguments: The arguments for the attribute, if any. + /// - Returns: A copy of the class with the attribute added. + public func attribute(_ attribute: String, arguments: [String] = []) -> Self { + var copy = self + copy.attributes.append(AttributeInfo(name: attribute, arguments: arguments)) + return copy + } +} diff --git a/Sources/SyntaxKit/Declarations/Class.swift b/Sources/SyntaxKit/Declarations/Class.swift index f20e577..98c2ca9 100644 --- a/Sources/SyntaxKit/Declarations/Class.swift +++ b/Sources/SyntaxKit/Declarations/Class.swift @@ -31,13 +31,13 @@ public import SwiftSyntax /// A Swift `class` declaration. public struct Class: CodeBlock, Sendable { - private let name: String - private let members: [any CodeBlock] - private var inheritance: [String] = [] - private var genericParameters: [String] = [] - private var isFinal: Bool = false - private var attributes: [AttributeInfo] = [] - private var accessModifier: AccessModifier? + internal let name: String + internal let members: [any CodeBlock] + internal var inheritance: [String] = [] + internal var genericParameters: [String] = [] + internal var isFinal: Bool = false + internal var attributes: [AttributeInfo] = [] + internal var accessModifier: AccessModifier? /// The SwiftSyntax representation of this class declaration. public var syntax: any SyntaxProtocol { @@ -141,52 +141,6 @@ public struct Class: CodeBlock, Sendable { self.members = try content() } - /// Sets the generic parameters for the class. - /// - Parameter generics: The list of generic parameter names. - /// - Returns: A copy of the class with the generic parameters set. - public func generic(_ generics: String...) -> Self { - var copy = self - copy.genericParameters = generics - return copy - } - - /// Sets the inheritance for the class. - /// - Parameter inheritance: The types to inherit from. - /// - Returns: A copy of the class with the inheritance set. - public func inherits(_ inheritance: String...) -> Self { - var copy = self - copy.inheritance = inheritance - return copy - } - - /// Marks the class declaration as `final`. - /// - Returns: A copy of the class marked as `final`. - public func final() -> Self { - var copy = self - copy.isFinal = true - return copy - } - - /// Sets the access modifier for the class declaration. - /// - Parameter access: The access modifier. - /// - Returns: A copy of the class with the access modifier set. - public func access(_ access: AccessModifier) -> Self { - var copy = self - copy.accessModifier = access - return copy - } - - /// Adds an attribute to the class declaration. - /// - Parameters: - /// - attribute: The attribute name (without the @ symbol). - /// - arguments: The arguments for the attribute, if any. - /// - Returns: A copy of the class with the attribute added. - public func attribute(_ attribute: String, arguments: [String] = []) -> Self { - var copy = self - copy.attributes.append(AttributeInfo(name: attribute, arguments: arguments)) - return copy - } - private func buildAttributeList(from attributes: [AttributeInfo]) -> AttributeListSyntax { if attributes.isEmpty { return AttributeListSyntax([]) diff --git a/Sources/SyntaxKit/Declarations/IfCanImport.swift b/Sources/SyntaxKit/Declarations/IfCanImport.swift index 0eecd77..948f4c1 100644 --- a/Sources/SyntaxKit/Declarations/IfCanImport.swift +++ b/Sources/SyntaxKit/Declarations/IfCanImport.swift @@ -3,11 +3,11 @@ // SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without +// files (the “Software”), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT @@ -34,27 +34,15 @@ public struct IfCanImport: CodeBlock, Sendable { private let moduleName: String private let content: [any CodeBlock] - /// Creates a `#if canImport(moduleName)` block wrapping the given content. - /// - Parameters: - /// - moduleName: The module name passed to `canImport`. - /// - content: The code blocks to emit when the module is available. - public init(_ moduleName: String, @CodeBlockBuilderResult _ content: () -> [any CodeBlock]) { - self.moduleName = moduleName - self.content = content() - } - + /// The SwiftSyntax representation of this conditional compilation block. public var syntax: any SyntaxProtocol { + let canImportRef = DeclReferenceExprSyntax(baseName: .identifier("canImport")) + let moduleRef = DeclReferenceExprSyntax(baseName: .identifier(moduleName)) let condition = FunctionCallExprSyntax( - calledExpression: ExprSyntax(DeclReferenceExprSyntax( - baseName: .identifier("canImport") - )), + calledExpression: ExprSyntax(canImportRef), leftParen: .leftParenToken(), arguments: LabeledExprListSyntax([ - LabeledExprSyntax( - expression: ExprSyntax(DeclReferenceExprSyntax( - baseName: .identifier(moduleName) - )) - ) + LabeledExprSyntax(expression: ExprSyntax(moduleRef)) ]), rightParen: .rightParenToken() ) @@ -78,4 +66,13 @@ public struct IfCanImport: CodeBlock, Sendable { poundEndif: .poundEndifToken(leadingTrivia: .newline) ) } + + /// Creates a `#if canImport(moduleName)` block wrapping the given content. + /// - Parameters: + /// - moduleName: The module name passed to `canImport`. + /// - content: The code blocks to emit when the module is available. + public init(_ moduleName: String, @CodeBlockBuilderResult _ content: () -> [any CodeBlock]) { + self.moduleName = moduleName + self.content = content() + } } diff --git a/Sources/SyntaxKit/Declarations/Initializer.swift b/Sources/SyntaxKit/Declarations/Initializer.swift index 8fd34cf..2e732ec 100644 --- a/Sources/SyntaxKit/Declarations/Initializer.swift +++ b/Sources/SyntaxKit/Declarations/Initializer.swift @@ -3,11 +3,11 @@ // SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without +// files (the “Software”), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Sources/SyntaxKit/Utilities/AttributeArgument.swift b/Sources/SyntaxKit/Utilities/AttributeArgument.swift index b940b45..dbf97e8 100644 --- a/Sources/SyntaxKit/Utilities/AttributeArgument.swift +++ b/Sources/SyntaxKit/Utilities/AttributeArgument.swift @@ -3,11 +3,11 @@ // SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without +// files (the “Software”), to deal in the Software without // restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the @@ -17,7 +17,7 @@ // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift new file mode 100644 index 0000000..d85c700 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift @@ -0,0 +1,137 @@ +// +// AttributeTests+Arguments.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit +import Testing + +extension AttributeTests { + @Suite("Arguments") internal struct Arguments { + @Test("Attribute with arguments generates correct syntax") + internal func testAttributeWithArguments() throws { + let attribute = Attribute("available", arguments: ["iOS", "17.0", "*"]) + + let generated = attribute.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("*")) + } + + @Test("Attribute with single argument generates correct syntax") + internal func testAttributeWithSingleArgument() throws { + let attribute = Attribute("available", argument: "iOS 17.0") + + let generated = attribute.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS 17.0")) + } + + @Test("Function with attribute arguments generates correct syntax") + internal func testFunctionWithAttributeArguments() throws { + let function = Function("bar") { + Variable(.let, name: "message", type: "String", equals: "bar") + }.attribute("available", arguments: ["iOS", "17.0", "*"]) + + let generated = function.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("*")) + #expect(generated.contains("func bar")) + } + + @Test("Class with attribute arguments generates correct syntax") + internal func testClassWithAttributeArguments() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + }.attribute("available", arguments: ["iOS", "17.0"]) + + let generated = classDecl.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("class Foo")) + } + + @Test("Variable with attribute arguments generates correct syntax") + internal func testVariableWithAttributeArguments() throws { + let variable = Variable(.var, name: "bar", type: "String", equals: "bar") + .attribute("available", arguments: ["iOS", "17.0"]) + + let generated = variable.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("var bar")) + } + + @Test("Struct with quoted string attribute argument generates string literal, not identifier") + internal func testStructWithStringLiteralAttributeArgument() throws { + // Fix 3 regression: "\"App Model\"" must produce @Suite("App Model"), not @Suite(App Model) + let structDecl = Struct("AppModelTests") {} + .attribute("Suite", arguments: ["\"App Model\""]) + + let generated = structDecl.syntax.description + #expect( + generated.contains("@Suite(\"App Model\")") || generated.contains("@Suite( \"App Model\")")) + #expect(!generated.contains("@Suite(App Model)")) + } + + @Test("Function with quoted string attribute argument generates string literal") + internal func testFunctionWithStringLiteralAttributeArgument() throws { + // Fix 3 regression: quoted argument must produce string literal token + let function = Function("initialCount") {} + .attribute("Test", arguments: ["\"Initial count is zero\""]) + + let generated = function.syntax.description + // The argument should be a string literal: @Test("Initial count is zero") + #expect( + generated.contains("@Test(\"Initial count is zero\")") + || generated.contains("@Test( \"Initial count is zero\")")) + } + + @Test("Parameter with attribute arguments generates correct syntax") + internal func testParameterWithAttributeArguments() throws { + let function = Function("validate") { + Parameter(name: "input", type: "String") + .attribute("available", arguments: ["iOS", "17.0"]) + } _: { + Variable(.let, name: "result", type: "Bool", equals: "true") + } + + let generated = function.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("input : String")) + #expect(generated.contains("func validate")) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift new file mode 100644 index 0000000..59236b7 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift @@ -0,0 +1,137 @@ +// +// AttributeTests+Targets.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit +import Testing + +extension AttributeTests { + @Suite("Targets") internal struct Targets { + @Test("Class with attribute generates correct syntax") + internal func testClassWithAttribute() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + }.attribute("objc") + + let generated = classDecl.syntax.description + #expect(generated.contains("@objc")) + #expect(generated.contains("class Foo")) + } + + @Test("Function with attribute generates correct syntax") + internal func testFunctionWithAttribute() throws { + let function = Function("bar") { + Variable(.let, name: "message", type: "String", equals: "bar") + }.attribute("available") + + let generated = function.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("func bar")) + } + + @Test("Variable with attribute generates correct syntax") + internal func testVariableWithAttribute() throws { + let variable = Variable(.var, name: "bar", type: "String", equals: "bar") + .attribute("Published") + + let generated = variable.syntax.description + #expect(generated.contains("@Published")) + #expect(generated.contains("var bar")) + } + + @Test("Multiple attributes on class generates correct syntax") + internal func testMultipleAttributesOnClass() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + } + .attribute("objc") + .attribute("MainActor") + + let generated = classDecl.syntax.description + #expect(generated.contains("@objc")) + #expect(generated.contains("@MainActor")) + #expect(generated.contains("class Foo")) + } + + @Test("Comprehensive attribute example generates correct syntax") + internal func testComprehensiveAttributeExample() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + .attribute("Published") + + Function("bar") { + Variable(.let, name: "message", type: "String", equals: "bar") + } + .attribute("available") + .attribute("MainActor") + + Function("baz") { + Variable(.let, name: "message", type: "String", equals: "baz") + } + .attribute("MainActor") + }.attribute("objc") + + let generated = classDecl.syntax.description + #expect(generated.contains("@objc")) + #expect(generated.contains("@Published")) + #expect(generated.contains("@available")) + #expect(generated.contains("@MainActor")) + #expect(generated.contains("class Foo")) + #expect(generated.contains("var bar")) + #expect(generated.contains("func bar")) + #expect(generated.contains("func baz")) + } + + @Test("Parameter with attribute generates correct syntax") + internal func testParameterWithAttribute() throws { + let function = Function("process") { + Parameter(name: "data", type: "Data") + .attribute("escaping") + } _: { + Variable(.let, name: "result", type: "String", equals: "processed") + } + + let generated = function.syntax.description + #expect(generated.contains("@escaping")) + #expect(generated.contains("data : Data")) + #expect(generated.contains("func process")) + } + + @Test("Struct with unquoted attribute argument generates identifier, not string literal") + internal func testStructWithIdentifierAttributeArgument() throws { + // Unquoted args should remain as identifier references + let structDecl = Struct("Serve") {} + .attribute("main") + + let generated = structDecl.syntax.description + #expect(generated.contains("@main")) + #expect(generated.contains("struct Serve")) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift index e7d5de4..88d33b3 100644 --- a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift @@ -27,207 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import SyntaxKit import Testing -@Suite internal struct AttributeTests { - @Test("Class with attribute generates correct syntax") - internal func testClassWithAttribute() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - }.attribute("objc") - - let generated = classDecl.syntax.description - #expect(generated.contains("@objc")) - #expect(generated.contains("class Foo")) - } - - @Test("Function with attribute generates correct syntax") - internal func testFunctionWithAttribute() throws { - let function = Function("bar") { - Variable(.let, name: "message", type: "String", equals: "bar") - }.attribute("available") - - let generated = function.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("func bar")) - } - - @Test("Variable with attribute generates correct syntax") - internal func testVariableWithAttribute() throws { - let variable = Variable(.var, name: "bar", type: "String", equals: "bar") - .attribute("Published") - - let generated = variable.syntax.description - #expect(generated.contains("@Published")) - #expect(generated.contains("var bar")) - } - - @Test("Multiple attributes on class generates correct syntax") - internal func testMultipleAttributesOnClass() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - } - .attribute("objc") - .attribute("MainActor") - - let generated = classDecl.syntax.description - #expect(generated.contains("@objc")) - #expect(generated.contains("@MainActor")) - #expect(generated.contains("class Foo")) - } - - @Test("Attribute with arguments generates correct syntax") - internal func testAttributeWithArguments() throws { - let attribute = Attribute("available", arguments: ["iOS", "17.0", "*"]) - - let generated = attribute.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("*")) - } - - @Test("Attribute with single argument generates correct syntax") - internal func testAttributeWithSingleArgument() throws { - let attribute = Attribute("available", argument: "iOS 17.0") - - let generated = attribute.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS 17.0")) - } - - @Test("Comprehensive attribute example generates correct syntax") - internal func testComprehensiveAttributeExample() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - .attribute("Published") - - Function("bar") { - Variable(.let, name: "message", type: "String", equals: "bar") - } - .attribute("available") - .attribute("MainActor") - - Function("baz") { - Variable(.let, name: "message", type: "String", equals: "baz") - } - .attribute("MainActor") - }.attribute("objc") - - let generated = classDecl.syntax.description - #expect(generated.contains("@objc")) - #expect(generated.contains("@Published")) - #expect(generated.contains("@available")) - #expect(generated.contains("@MainActor")) - #expect(generated.contains("class Foo")) - #expect(generated.contains("var bar")) - #expect(generated.contains("func bar")) - #expect(generated.contains("func baz")) - } - - @Test("Function with attribute arguments generates correct syntax") - internal func testFunctionWithAttributeArguments() throws { - let function = Function("bar") { - Variable(.let, name: "message", type: "String", equals: "bar") - }.attribute("available", arguments: ["iOS", "17.0", "*"]) - - let generated = function.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("*")) - #expect(generated.contains("func bar")) - } - - @Test("Class with attribute arguments generates correct syntax") - internal func testClassWithAttributeArguments() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - }.attribute("available", arguments: ["iOS", "17.0"]) - - let generated = classDecl.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("class Foo")) - } - - @Test("Variable with attribute arguments generates correct syntax") - internal func testVariableWithAttributeArguments() throws { - let variable = Variable(.var, name: "bar", type: "String", equals: "bar") - .attribute("available", arguments: ["iOS", "17.0"]) - - let generated = variable.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("var bar")) - } - - @Test("Parameter with attribute generates correct syntax") - internal func testParameterWithAttribute() throws { - let function = Function("process") { - Parameter(name: "data", type: "Data") - .attribute("escaping") - } _: { - Variable(.let, name: "result", type: "String", equals: "processed") - } - - let generated = function.syntax.description - #expect(generated.contains("@escaping")) - #expect(generated.contains("data : Data")) - #expect(generated.contains("func process")) - } - - @Test("Struct with quoted string attribute argument generates string literal, not identifier") - internal func testStructWithStringLiteralAttributeArgument() throws { - // Fix 3 regression: "\"App Model\"" must produce @Suite("App Model"), not @Suite(App Model) - let structDecl = Struct("AppModelTests") {} - .attribute("Suite", arguments: ["\"App Model\""]) - - let generated = structDecl.syntax.description - #expect(generated.contains("@Suite(\"App Model\")") || generated.contains("@Suite( \"App Model\")")) - #expect(!generated.contains("@Suite(App Model)")) - } - - @Test("Function with quoted string attribute argument generates string literal") - internal func testFunctionWithStringLiteralAttributeArgument() throws { - // Fix 3 regression: quoted argument must produce string literal token - let function = Function("initialCount") {} - .attribute("Test", arguments: ["\"Initial count is zero\""]) - - let generated = function.syntax.description - // The argument should be a string literal: @Test("Initial count is zero") - #expect(generated.contains("@Test(\"Initial count is zero\")") || generated.contains("@Test( \"Initial count is zero\")")) - } - - @Test("Struct with unquoted attribute argument generates identifier, not string literal") - internal func testStructWithIdentifierAttributeArgument() throws { - // Unquoted args should remain as identifier references - let structDecl = Struct("Serve") {} - .attribute("main") - - let generated = structDecl.syntax.description - #expect(generated.contains("@main")) - #expect(generated.contains("struct Serve")) - } - - @Test("Parameter with attribute arguments generates correct syntax") - internal func testParameterWithAttributeArguments() throws { - let function = Function("validate") { - Parameter(name: "input", type: "String") - .attribute("available", arguments: ["iOS", "17.0"]) - } _: { - Variable(.let, name: "result", type: "Bool", equals: "true") - } - - let generated = function.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("input : String")) - #expect(generated.contains("func validate")) - } -} +/// Namespace for the attribute generation test suites. +@Suite("Attributes") internal enum AttributeTests {} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift new file mode 100644 index 0000000..00e1a96 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift @@ -0,0 +1,159 @@ +// +// ClassTests+Declarations.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +extension ClassTests { + @Suite("Declarations") internal struct Declarations { + @Test internal func testClassWithInheritance() { + let carClass = Class("Car") { + Variable(.var, name: "brand", type: "String").withExplicitType() + Variable(.var, name: "numberOfWheels", type: "Int").withExplicitType() + }.inherits("Vehicle") + + let expected = """ + class Car: Vehicle { + var brand: String + var numberOfWheels: Int + } + """ + + let normalizedGenerated = carClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testEmptyClass() { + let emptyClass = Class("EmptyClass") {} + + let expected = """ + class EmptyClass { + } + """ + + let normalizedGenerated = emptyClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithGenerics() { + let genericClass = Class("Container") { + Variable(.var, name: "value", type: "T").withExplicitType() + }.generic("T") + + let expected = """ + class Container { + var value: T + } + """ + + let normalizedGenerated = genericClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithMultipleGenerics() { + let multiGenericClass = Class("Pair") { + Variable(.var, name: "first", type: "T").withExplicitType() + Variable(.var, name: "second", type: "U").withExplicitType() + }.generic("T", "U") + + let expected = """ + class Pair { + var first: T + var second: U + } + """ + + let normalizedGenerated = multiGenericClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithMultipleInheritance() { + let classWithMultipleInheritance = Class("AdvancedVehicle") { + Variable(.var, name: "speed", type: "Int").withExplicitType() + }.inherits("Vehicle") + + let expected = """ + class AdvancedVehicle: Vehicle { + var speed: Int + } + """ + + let normalizedGenerated = classWithMultipleInheritance.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithGenericsAndInheritance() { + let genericClassWithInheritance = Class("GenericContainer") { + Variable(.var, name: "items", type: "[T]").withExplicitType() + }.generic("T").inherits("Collection") + + let expected = """ + class GenericContainer: Collection { + var items: [T] + } + """ + + let normalizedGenerated = genericClassWithInheritance.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithFunctions() { + let classWithFunctions = Class("Calculator") { + Function("add", returns: "Int") { + Parameter(name: "a", type: "Int") + Parameter(name: "b", type: "Int") + } _: { + Return { + VariableExp("a + b") + } + } + } + + let expected = """ + class Calculator { + func add(a: Int, b: Int) -> Int { + return a + b + } + } + """ + + let normalizedGenerated = classWithFunctions.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift new file mode 100644 index 0000000..2996797 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift @@ -0,0 +1,114 @@ +// +// ClassTests+Modifiers.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +extension ClassTests { + @Suite("Modifiers") internal struct Modifiers { + @Test internal func testFinalClass() { + let finalClass = Class("FinalClass") { + Variable(.var, name: "value", type: "String").withExplicitType() + }.final() + + let expected = """ + final class FinalClass { + var value: String + } + """ + + let normalizedGenerated = finalClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testFinalClassWithInheritanceAndGenerics() { + let finalGenericClass = Class("FinalGenericClass") { + Variable(.var, name: "value", type: "T").withExplicitType() + }.generic("T").inherits("BaseClass").final() + + let expected = """ + final class FinalGenericClass: BaseClass { + var value: T + } + """ + + let normalizedGenerated = finalGenericClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicClass() { + let publicClass = Class("AppModel") {}.access(.public) + + let expected = """ + public class AppModel { + } + """ + + let normalizedGenerated = publicClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicFinalClass() throws { + let publicFinalClass = Class("AppModel") {} + .attribute("Observable") + .access(.public) + .final() + .inherits("Sendable") + + let generated = publicFinalClass.generateCode() + // Fix 2 regression: Class must support .access() + #expect(generated.contains("public")) + #expect(generated.contains("final")) + #expect(generated.contains("class AppModel")) + #expect(generated.contains("Sendable")) + // Access modifier must precede final + let publicRange = try #require(generated.range(of: "public")) + let finalRange = try #require(generated.range(of: "final")) + #expect(publicRange.lowerBound < finalRange.lowerBound) + } + + @Test internal func testInternalClass() { + let internalClass = Class("MyClass") {}.access(.internal) + + let expected = """ + internal class MyClass { + } + """ + + let normalizedGenerated = internalClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift index aa3c813..995afe5 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift @@ -27,208 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation import Testing -@testable import SyntaxKit - -internal struct ClassTests { - @Test internal func testClassWithInheritance() { - let carClass = Class("Car") { - Variable(.var, name: "brand", type: "String").withExplicitType() - Variable(.var, name: "numberOfWheels", type: "Int").withExplicitType() - }.inherits("Vehicle") - - let expected = """ - class Car: Vehicle { - var brand: String - var numberOfWheels: Int - } - """ - - let normalizedGenerated = carClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testEmptyClass() { - let emptyClass = Class("EmptyClass") {} - - let expected = """ - class EmptyClass { - } - """ - - let normalizedGenerated = emptyClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithGenerics() { - let genericClass = Class("Container") { - Variable(.var, name: "value", type: "T").withExplicitType() - }.generic("T") - - let expected = """ - class Container { - var value: T - } - """ - - let normalizedGenerated = genericClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithMultipleGenerics() { - let multiGenericClass = Class("Pair") { - Variable(.var, name: "first", type: "T").withExplicitType() - Variable(.var, name: "second", type: "U").withExplicitType() - }.generic("T", "U") - - let expected = """ - class Pair { - var first: T - var second: U - } - """ - - let normalizedGenerated = multiGenericClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testFinalClass() { - let finalClass = Class("FinalClass") { - Variable(.var, name: "value", type: "String").withExplicitType() - }.final() - - let expected = """ - final class FinalClass { - var value: String - } - """ - - let normalizedGenerated = finalClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithMultipleInheritance() { - let classWithMultipleInheritance = Class("AdvancedVehicle") { - Variable(.var, name: "speed", type: "Int").withExplicitType() - }.inherits("Vehicle") - - let expected = """ - class AdvancedVehicle: Vehicle { - var speed: Int - } - """ - - let normalizedGenerated = classWithMultipleInheritance.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithGenericsAndInheritance() { - let genericClassWithInheritance = Class("GenericContainer") { - Variable(.var, name: "items", type: "[T]").withExplicitType() - }.generic("T").inherits("Collection") - - let expected = """ - class GenericContainer: Collection { - var items: [T] - } - """ - - let normalizedGenerated = genericClassWithInheritance.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testFinalClassWithInheritanceAndGenerics() { - let finalGenericClass = Class("FinalGenericClass") { - Variable(.var, name: "value", type: "T").withExplicitType() - }.generic("T").inherits("BaseClass").final() - - let expected = """ - final class FinalGenericClass: BaseClass { - var value: T - } - """ - - let normalizedGenerated = finalGenericClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testPublicClass() { - let publicClass = Class("AppModel") {}.access(.public) - - let expected = """ - public class AppModel { - } - """ - - let normalizedGenerated = publicClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testPublicFinalClass() { - let publicFinalClass = Class("AppModel") {} - .attribute("Observable") - .access(.public) - .final() - .inherits("Sendable") - - let generated = publicFinalClass.generateCode() - // Fix 2 regression: Class must support .access() - #expect(generated.contains("public")) - #expect(generated.contains("final")) - #expect(generated.contains("class AppModel")) - #expect(generated.contains("Sendable")) - // Access modifier must precede final - let publicRange = generated.range(of: "public")! - let finalRange = generated.range(of: "final")! - #expect(publicRange.lowerBound < finalRange.lowerBound) - } - - @Test internal func testInternalClass() { - let internalClass = Class("MyClass") {}.access(.internal) - - let expected = """ - internal class MyClass { - } - """ - - let normalizedGenerated = internalClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithFunctions() { - let classWithFunctions = Class("Calculator") { - Function("add", returns: "Int") { - Parameter(name: "a", type: "Int") - Parameter(name: "b", type: "Int") - } _: { - Return { - VariableExp("a + b") - } - } - } - - let expected = """ - class Calculator { - func add(a: Int, b: Int) -> Int { - return a + b - } - } - """ - - let normalizedGenerated = classWithFunctions.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } -} +/// Namespace for the `Class` declaration test suites. +@Suite("Class") internal enum ClassTests {} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift index e6a9d66..93fc42c 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift @@ -1,3 +1,32 @@ +// +// ImportTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift index 80f2f99..63cb6ec 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift @@ -1,3 +1,32 @@ +// +// InitializerTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing From 00aaec1844470e2a3929782760c44e6bd519d385 Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 11 Jun 2026 10:32:54 -0400 Subject: [PATCH 3/4] Address PR #125 reviews: PoundIf, InitializerDecl rename, lint.sh revert Resolve all three review comments on PR #125. - Rename Initializer -> InitializerDecl (mirrors InitializerDeclSyntax) to disambiguate from the existing Init expression DSL. Updates the matching test struct and call sites. - Replace IfCanImport with a generic PoundIf #if/#elseif/#else/#endif block (split across PoundIf.swift, PoundIf+Condition.swift, and PoundIf+Rendering.swift). PoundIf.Condition covers canImport, flag, os, arch, targetEnvironment, swift, compiler, hasFeature, hasAttribute, and boolean combinators (and/or/not). Also accepts raw String and any CodeBlock conditions plus .elseif / .else modifiers. Delivers the core of #140 under the PoundIf name; see issue #140 for the SwiftSyntax pound taxonomy and the boundary against MacroExp (#141). - New PoundIfTests covers each helper case, raw/CodeBlock conditions, elseif/else chaining, and the former IfCanImport shape (17 tests). - Revert the mise-trust/install block added to Scripts/lint.sh in 1fd6c9e per review request. swift build + 485 tests pass; swift-format and swiftlint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Scripts/lint.sh | 6 - .../SyntaxKit/Declarations/IfCanImport.swift | 78 ------- ...nitializer.swift => InitializerDecl.swift} | 4 +- .../Declarations/PoundIf+Condition.swift | 162 ++++++++++++++ .../Declarations/PoundIf+Rendering.swift | 119 +++++++++++ Sources/SyntaxKit/Declarations/PoundIf.swift | 172 +++++++++++++++ ...Tests.swift => InitializerDeclTests.swift} | 14 +- .../Unit/Declarations/PoundIfTests.swift | 202 ++++++++++++++++++ 8 files changed, 664 insertions(+), 93 deletions(-) delete mode 100644 Sources/SyntaxKit/Declarations/IfCanImport.swift rename Sources/SyntaxKit/Declarations/{Initializer.swift => InitializerDecl.swift} (98%) create mode 100644 Sources/SyntaxKit/Declarations/PoundIf+Condition.swift create mode 100644 Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift create mode 100644 Sources/SyntaxKit/Declarations/PoundIf.swift rename Tests/SyntaxKitTests/Unit/Declarations/{InitializerTests.swift => InitializerDeclTests.swift} (90%) create mode 100644 Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 457bc4c..5769c60 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -25,12 +25,6 @@ fi # Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then - # Trust the config (no-op if already trusted) so `mise env` doesn't silently - # emit nothing when mise.toml is new/untrusted, which would leave the - # mise-managed tools (swiftlint, periphery, ...) off PATH. - mise trust --quiet "$PACKAGE_DIR/mise.toml" >/dev/null 2>&1 || true - # Install any declared-but-missing tools so they resolve on PATH. - mise -C "$PACKAGE_DIR" install >/dev/null 2>&1 || true eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi diff --git a/Sources/SyntaxKit/Declarations/IfCanImport.swift b/Sources/SyntaxKit/Declarations/IfCanImport.swift deleted file mode 100644 index 948f4c1..0000000 --- a/Sources/SyntaxKit/Declarations/IfCanImport.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// IfCanImport.swift -// SyntaxKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import SwiftSyntax - -/// A `#if canImport(Module)` … `#endif` conditional compilation block. -public struct IfCanImport: CodeBlock, Sendable { - private let moduleName: String - private let content: [any CodeBlock] - - /// The SwiftSyntax representation of this conditional compilation block. - public var syntax: any SyntaxProtocol { - let canImportRef = DeclReferenceExprSyntax(baseName: .identifier("canImport")) - let moduleRef = DeclReferenceExprSyntax(baseName: .identifier(moduleName)) - let condition = FunctionCallExprSyntax( - calledExpression: ExprSyntax(canImportRef), - leftParen: .leftParenToken(), - arguments: LabeledExprListSyntax([ - LabeledExprSyntax(expression: ExprSyntax(moduleRef)) - ]), - rightParen: .rightParenToken() - ) - - let items = CodeBlockItemListSyntax( - content.compactMap { block -> CodeBlockItemSyntax? in - CodeBlockItemSyntax.Item.create(from: block.syntax).map { - CodeBlockItemSyntax(item: $0, trailingTrivia: .newline) - } - } - ) - - let clause = IfConfigClauseSyntax( - poundKeyword: .poundIfToken(trailingTrivia: .space), - condition: ExprSyntax(condition).with(\.trailingTrivia, .newline), - elements: .statements(items) - ) - - return IfConfigDeclSyntax( - clauses: IfConfigClauseListSyntax([clause]), - poundEndif: .poundEndifToken(leadingTrivia: .newline) - ) - } - - /// Creates a `#if canImport(moduleName)` block wrapping the given content. - /// - Parameters: - /// - moduleName: The module name passed to `canImport`. - /// - content: The code blocks to emit when the module is available. - public init(_ moduleName: String, @CodeBlockBuilderResult _ content: () -> [any CodeBlock]) { - self.moduleName = moduleName - self.content = content() - } -} diff --git a/Sources/SyntaxKit/Declarations/Initializer.swift b/Sources/SyntaxKit/Declarations/InitializerDecl.swift similarity index 98% rename from Sources/SyntaxKit/Declarations/Initializer.swift rename to Sources/SyntaxKit/Declarations/InitializerDecl.swift index 2e732ec..ef2e4ab 100644 --- a/Sources/SyntaxKit/Declarations/Initializer.swift +++ b/Sources/SyntaxKit/Declarations/InitializerDecl.swift @@ -1,5 +1,5 @@ // -// Initializer.swift +// InitializerDecl.swift // SyntaxKit // // Created by Leo Dion. @@ -30,7 +30,7 @@ public import SwiftSyntax /// A Swift `init` declaration. -public struct Initializer: CodeBlock, Sendable { +public struct InitializerDecl: CodeBlock, Sendable { private let body: [any CodeBlock] private var accessModifier: AccessModifier? private var isAsync: Bool = false diff --git a/Sources/SyntaxKit/Declarations/PoundIf+Condition.swift b/Sources/SyntaxKit/Declarations/PoundIf+Condition.swift new file mode 100644 index 0000000..70fe247 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/PoundIf+Condition.swift @@ -0,0 +1,162 @@ +// +// PoundIf+Condition.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension PoundIf { + // swiftlint:disable identifier_name + + /// Canonical `#if` checks, mirroring Swift's conditional-compilation grammar. + public indirect enum Condition: Sendable { + /// `canImport()` + case canImport(String) + /// A bare compilation flag such as `DEBUG`. + case flag(String) + /// `os()` + case os(OperatingSystem) + /// `arch()` + case arch(Architecture) + /// `targetEnvironment()` + case targetEnvironment(TargetEnvironment) + /// `swift(>=5.9)` and friends. + case swift(VersionCheck) + /// `compiler(>=5.9)` and friends. + case compiler(VersionCheck) + /// `hasFeature()` + case hasFeature(String) + /// `hasAttribute()` + case hasAttribute(String) + /// ` && ` + case and(Condition, Condition) + /// ` || ` + case or(Condition, Condition) + /// `!` + case not(Condition) + } + + // swiftlint:enable identifier_name + + /// Operating-system identifiers used in `os(...)` checks. + public enum OperatingSystem: String, Sendable { + /// `os(iOS)` + case iOS + /// `os(macOS)` + case macOS + /// `os(tvOS)` + case tvOS + /// `os(watchOS)` + case watchOS + /// `os(visionOS)` + case visionOS + /// `os(Linux)` + case linux = "Linux" + /// `os(Windows)` + case windows = "Windows" + /// `os(FreeBSD)` + case freeBSD = "FreeBSD" + /// `os(Android)` + case android = "Android" + /// `os(WASI)` + case wasi = "WASI" + } + + /// CPU-architecture identifiers used in `arch(...)` checks. + public enum Architecture: String, Sendable { + /// `arch(arm64)` + case arm64 + /// `arch(x86_64)` + case x86 = "x86_64" + /// `arch(i386)` + case i386 + /// `arch(arm)` + case arm + /// `arch(wasm32)` + case wasm32 + } + + /// Target-environment identifiers used in `targetEnvironment(...)` checks. + public enum TargetEnvironment: String, Sendable { + /// `targetEnvironment(simulator)` + case simulator + /// `targetEnvironment(macCatalyst)` + case macCatalyst + } + + /// A `swift(>=5.9)` / `compiler(>=5.9)`-style version check. + public struct VersionCheck: Sendable { + /// The comparison operator used between the keyword and the version. + public enum Comparison: String, Sendable { + /// `>=` + case greaterThanOrEqual = ">=" + /// `>` + case greaterThan = ">" + /// `<=` + case lessThanOrEqual = "<=" + /// `<` + case lessThan = "<" + /// `==` + case equal = "==" + } + + /// The comparison operator that precedes the version. + public let comparison: Comparison + /// The major version component. + public let major: Int + /// The optional minor version component. + public let minor: Int? + /// The optional patch version component. + public let patch: Int? + + internal var versionString: String { + var result = String(major) + if let minor = minor { + result += ".\(minor)" + if let patch = patch { + result += ".\(patch)" + } + } + return result + } + + internal var rendered: String { + "\(comparison.rawValue)\(versionString)" + } + + /// Build a `>= major.minor[.patch]` check. + public static func atLeast(_ major: Int, _ minor: Int? = nil, _ patch: Int? = nil) + -> VersionCheck + { + VersionCheck(comparison: .greaterThanOrEqual, major: major, minor: minor, patch: patch) + } + + /// Build an `== major.minor[.patch]` check. + public static func exact(_ major: Int, _ minor: Int? = nil, _ patch: Int? = nil) -> VersionCheck + { + VersionCheck(comparison: .equal, major: major, minor: minor, patch: patch) + } + } +} diff --git a/Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift b/Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift new file mode 100644 index 0000000..2a71131 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift @@ -0,0 +1,119 @@ +// +// PoundIf+Rendering.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import SwiftParser +internal import SwiftSyntax + +extension PoundIf { + internal static func makeClause( + poundKeyword: TokenSyntax, + condition: ConditionForm?, + body: [any CodeBlock] + ) -> IfConfigClauseSyntax { + let items = CodeBlockItemListSyntax( + body.compactMap { block -> CodeBlockItemSyntax? in + CodeBlockItemSyntax.Item.create(from: block.syntax).map { + CodeBlockItemSyntax(item: $0, trailingTrivia: .newline) + } + } + ) + + let renderedCondition = condition.flatMap(Self.renderCondition)? + .with(\.trailingTrivia, .newline) + + return IfConfigClauseSyntax( + poundKeyword: poundKeyword, + condition: renderedCondition, + elements: .statements(items) + ) + } + + private static func renderCondition(_ form: ConditionForm) -> ExprSyntax? { + switch form { + case .helper(let condition): + return parseExpression(renderHelper(condition, atTopLevel: true)) + case .raw(let text): + return parseExpression(text) + case .codeBlock(let block): + if let expr = block.syntax.as(ExprSyntax.self) { + return expr + } + return parseExpression(block.generateCode()) + } + } + + private static func parseExpression(_ source: String) -> ExprSyntax? { + let file = Parser.parse(source: source) + for item in file.statements { + if let expr = item.item.as(ExprSyntax.self) { + return expr + } + } + return nil + } + + private static func renderHelper(_ condition: Condition, atTopLevel: Bool) -> String { + if let leaf = renderLeaf(condition) { + return leaf + } + return renderCombinator(condition, atTopLevel: atTopLevel) + } + + private static func renderLeaf(_ condition: Condition) -> String? { + switch condition { + case .canImport(let module): return "canImport(\(module))" + case .flag(let name): return name + case .os(let value): return "os(\(value.rawValue))" + case .arch(let value): return "arch(\(value.rawValue))" + case .targetEnvironment(let value): return "targetEnvironment(\(value.rawValue))" + case .swift(let check): return "swift(\(check.rendered))" + case .compiler(let check): return "compiler(\(check.rendered))" + case .hasFeature(let name): return "hasFeature(\(name))" + case .hasAttribute(let name): return "hasAttribute(\(name))" + case .and, .or, .not: return nil + } + } + + private static func renderCombinator(_ condition: Condition, atTopLevel: Bool) -> String { + switch condition { + case .and(let lhs, let rhs): + let inner = + "\(renderHelper(lhs, atTopLevel: false)) && \(renderHelper(rhs, atTopLevel: false))" + return atTopLevel ? inner : "(\(inner))" + case .or(let lhs, let rhs): + let inner = + "\(renderHelper(lhs, atTopLevel: false)) || \(renderHelper(rhs, atTopLevel: false))" + return atTopLevel ? inner : "(\(inner))" + case .not(let operand): + return "!\(renderHelper(operand, atTopLevel: false))" + default: + return "" + } + } +} diff --git a/Sources/SyntaxKit/Declarations/PoundIf.swift b/Sources/SyntaxKit/Declarations/PoundIf.swift new file mode 100644 index 0000000..a88d2f6 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/PoundIf.swift @@ -0,0 +1,172 @@ +// +// PoundIf.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import SwiftSyntax + +/// A `#if … #elseif … #else … #endif` conditional compilation block. +public struct PoundIf: CodeBlock, Sendable { + /// One of the three accepted condition forms attached to a `#if` / `#elseif` clause. + internal enum ConditionForm: Sendable { + case helper(Condition) + case raw(String) + case codeBlock(any CodeBlock) + } + + /// A single `#if`, `#elseif`, or `#else` clause and the code blocks inside it. + internal struct Clause: Sendable { + internal let condition: ConditionForm? + internal let body: [any CodeBlock] + } + + private let head: Clause + private var elseifClauses: [Clause] = [] + private var elseBody: [any CodeBlock]? + + /// The SwiftSyntax representation of this conditional compilation block. + public var syntax: any SyntaxProtocol { + var clauses: [IfConfigClauseSyntax] = [ + Self.makeClause( + poundKeyword: .poundIfToken(trailingTrivia: .space), + condition: head.condition, + body: head.body + ) + ] + + for clause in elseifClauses { + clauses.append( + Self.makeClause( + poundKeyword: .poundElseifToken(leadingTrivia: .newline, trailingTrivia: .space), + condition: clause.condition, + body: clause.body + ) + ) + } + + if let elseBody = elseBody { + clauses.append( + Self.makeClause( + poundKeyword: .poundElseToken(leadingTrivia: .newline, trailingTrivia: .newline), + condition: nil, + body: elseBody + ) + ) + } + + return IfConfigDeclSyntax( + clauses: IfConfigClauseListSyntax(clauses), + poundEndif: .poundEndifToken(leadingTrivia: .newline) + ) + } + + /// `#if ` using the helper enum. + /// - Parameters: + /// - condition: The structured condition for the `#if` clause. + /// - content: The code blocks to emit when the condition is satisfied. + public init( + _ condition: Condition, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) { + self.head = Clause(condition: .helper(condition), body: content()) + } + + /// `#if ` escape hatch for any expression the helper enum cannot express. + /// - Parameters: + /// - condition: The raw condition text to emit after `#if`. + /// - content: The code blocks to emit when the condition is satisfied. + public init( + _ condition: String, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) { + self.head = Clause(condition: .raw(condition), body: content()) + } + + /// `#if ` escape hatch for callers that already have an expression value. + /// - Parameters: + /// - condition: The expression-shaped condition for the `#if` clause. + /// - content: The code blocks to emit when the condition is satisfied. + public init( + _ condition: any CodeBlock, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) { + self.head = Clause(condition: .codeBlock(condition), body: content()) + } + + /// Append a `#elseif ` clause. + /// - Parameters: + /// - condition: The structured condition for the new `#elseif` clause. + /// - content: The code blocks to emit when the condition is satisfied. + /// - Returns: A copy of `self` with the new clause appended. + public func elseif( + _ condition: Condition, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseifClauses.append(Clause(condition: .helper(condition), body: content())) + return copy + } + + /// Append a `#elseif ` clause. + /// - Parameters: + /// - condition: The raw condition text to emit after `#elseif`. + /// - content: The code blocks to emit when the condition is satisfied. + /// - Returns: A copy of `self` with the new clause appended. + public func elseif( + _ condition: String, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseifClauses.append(Clause(condition: .raw(condition), body: content())) + return copy + } + + /// Append a `#elseif ` clause. + /// - Parameters: + /// - condition: The expression-shaped condition for the new `#elseif` clause. + /// - content: The code blocks to emit when the condition is satisfied. + /// - Returns: A copy of `self` with the new clause appended. + public func elseif( + _ condition: any CodeBlock, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseifClauses.append(Clause(condition: .codeBlock(condition), body: content())) + return copy + } + + /// Append a `#else` clause. + /// - Parameter content: The code blocks to emit when no earlier clause was satisfied. + /// - Returns: A copy of `self` with the `#else` clause set. + public func `else`( + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseBody = content() + return copy + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift similarity index 90% rename from Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift rename to Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift index 63cb6ec..bb85669 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/InitializerTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift @@ -1,5 +1,5 @@ // -// InitializerTests.swift +// InitializerDeclTests.swift // SyntaxKit // // Created by Leo Dion. @@ -32,9 +32,9 @@ import Testing @testable import SyntaxKit -internal struct InitializerTests { +internal struct InitializerDeclTests { @Test internal func testEmptyInit() { - let initDecl = Initializer {} + let initDecl = InitializerDecl {} let expected = """ init() { @@ -47,7 +47,7 @@ internal struct InitializerTests { } @Test internal func testPublicInit() { - let initDecl = Initializer {}.access(.public) + let initDecl = InitializerDecl {}.access(.public) let expected = """ public init() { @@ -60,7 +60,7 @@ internal struct InitializerTests { } @Test internal func testThrowingInit() { - let initDecl = Initializer {}.throwing() + let initDecl = InitializerDecl {}.throwing() let expected = """ init() throws { @@ -73,7 +73,7 @@ internal struct InitializerTests { } @Test internal func testAsyncInit() { - let initDecl = Initializer {}.async() + let initDecl = InitializerDecl {}.async() let expected = """ init() async { @@ -86,7 +86,7 @@ internal struct InitializerTests { } @Test internal func testPublicInitWithBody() { - let initDecl = Initializer { + let initDecl = InitializerDecl { Call("setup") }.access(.internal) diff --git a/Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift new file mode 100644 index 0000000..9f68e7c --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift @@ -0,0 +1,202 @@ +// +// PoundIfTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +internal struct PoundIfTests { + @Test internal func testCanImport() { + let block = PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("import SwiftUI")) + #expect(generated.contains("#endif")) + } + + @Test internal func testFlag() { + let block = PoundIf(.flag("DEBUG")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if DEBUG")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } + + @Test internal func testOS() { + let block = PoundIf(.os(.iOS)) { + Import("UIKit") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if os(iOS)")) + #expect(generated.contains("import UIKit")) + } + + @Test internal func testArch() { + let block = PoundIf(.arch(.arm64)) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if arch(arm64)")) + } + + @Test internal func testTargetEnvironment() { + let block = PoundIf(.targetEnvironment(.simulator)) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if targetEnvironment(simulator)")) + } + + @Test internal func testSwiftVersion() { + let block = PoundIf(.swift(.atLeast(5, 9))) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if swift(>=5.9)")) + } + + @Test internal func testCompilerVersion() { + let block = PoundIf(.compiler(.atLeast(5, 9))) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if compiler(>=5.9)")) + } + + @Test internal func testHasFeature() { + let block = PoundIf(.hasFeature("StrictConcurrency")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if hasFeature(StrictConcurrency)")) + } + + @Test internal func testHasAttribute() { + let block = PoundIf(.hasAttribute("retroactive")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if hasAttribute(retroactive)")) + } + + @Test internal func testAnd() { + let block = PoundIf(.and(.os(.iOS), .arch(.arm64))) { + Import("UIKit") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("os(iOS) && arch(arm64)")) + } + + @Test internal func testOrNot() { + let block = PoundIf(.or(.canImport("UIKit"), .not(.os(.macOS)))) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("canImport(UIKit) || !os(macOS)")) + } + + @Test internal func testRawStringCondition() { + let block = PoundIf("CUSTOM_FLAG") { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if CUSTOM_FLAG")) + } + + @Test internal func testCodeBlockCondition() { + let block = PoundIf(VariableExp("MY_FLAG")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if MY_FLAG")) + } + + @Test internal func testElseif() { + let block = + PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + .elseif(.canImport("UIKit")) { + Import("UIKit") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("#elseif canImport(UIKit)")) + #expect(generated.contains("import UIKit")) + } + + @Test internal func testElse() { + let block = + PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + .else { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("#else")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } + + @Test internal func testElseifElse() { + let block = + PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + .elseif(.canImport("UIKit")) { + Import("UIKit") + } + .else { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("#elseif canImport(UIKit)")) + #expect(generated.contains("#else")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } + + @Test internal func testFormerIfCanImportShape() { + let block = PoundIf(.canImport("Foundation")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(Foundation)")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } +} From d77a56f4a14faecd72a5e299dec06022247e36c2 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 11 Jun 2026 14:26:55 -0400 Subject: [PATCH 4/4] Address PR #125 review: de-global attribute helper, fix init effects & params - Convert global buildAttributeArgumentExpr(from:) to an ExprSyntax initializer (ExprSyntax(attributeArgument:)), satisfying the "no global functions" convention; update all 8 call sites and rename the file to ExprSyntax+AttributeArgument.swift. - InitializerDecl: render `init() async throws` with single spacing (drop redundant async trailing space) and use throwsClause instead of the deprecated throwsSpecifier. - InitializerDecl: support parameters via a @ParameterBuilderResult overload, reusing FunctionParameterSyntax.create. - Add regression tests for async-throws spacing and init parameters. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/SyntaxKit/Declarations/Class.swift | 2 +- Sources/SyntaxKit/Declarations/Enum.swift | 2 +- .../SyntaxKit/Declarations/Extension.swift | 2 +- Sources/SyntaxKit/Declarations/Import.swift | 2 +- .../Declarations/InitializerDecl.swift | 36 +++++++++-- Sources/SyntaxKit/Declarations/Protocol.swift | 2 +- Sources/SyntaxKit/Declarations/Struct.swift | 2 +- .../SyntaxKit/Functions/Function+Syntax.swift | 2 +- ...ift => ExprSyntax+AttributeArgument.swift} | 39 ++++++------ .../Variables/Variable+Attributes.swift | 2 +- .../Declarations/InitializerDeclTests.swift | 63 +++++++++++++++++++ 11 files changed, 122 insertions(+), 32 deletions(-) rename Sources/SyntaxKit/Utilities/{AttributeArgument.swift => ExprSyntax+AttributeArgument.swift} (56%) diff --git a/Sources/SyntaxKit/Declarations/Class.swift b/Sources/SyntaxKit/Declarations/Class.swift index 98c2ca9..5f26b6e 100644 --- a/Sources/SyntaxKit/Declarations/Class.swift +++ b/Sources/SyntaxKit/Declarations/Class.swift @@ -158,7 +158,7 @@ public struct Class: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Declarations/Enum.swift b/Sources/SyntaxKit/Declarations/Enum.swift index a633a63..9b58efc 100644 --- a/Sources/SyntaxKit/Declarations/Enum.swift +++ b/Sources/SyntaxKit/Declarations/Enum.swift @@ -134,7 +134,7 @@ public struct Enum: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Declarations/Extension.swift b/Sources/SyntaxKit/Declarations/Extension.swift index 175d163..5a8df56 100644 --- a/Sources/SyntaxKit/Declarations/Extension.swift +++ b/Sources/SyntaxKit/Declarations/Extension.swift @@ -131,7 +131,7 @@ public struct Extension: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Declarations/Import.swift b/Sources/SyntaxKit/Declarations/Import.swift index f452b58..089bb9c 100644 --- a/Sources/SyntaxKit/Declarations/Import.swift +++ b/Sources/SyntaxKit/Declarations/Import.swift @@ -101,7 +101,7 @@ public struct Import: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Declarations/InitializerDecl.swift b/Sources/SyntaxKit/Declarations/InitializerDecl.swift index ef2e4ab..e84b4a8 100644 --- a/Sources/SyntaxKit/Declarations/InitializerDecl.swift +++ b/Sources/SyntaxKit/Declarations/InitializerDecl.swift @@ -31,6 +31,7 @@ public import SwiftSyntax /// A Swift `init` declaration. public struct InitializerDecl: CodeBlock, Sendable { + private let parameters: [Parameter] private let body: [any CodeBlock] private var accessModifier: AccessModifier? private var isAsync: Bool = false @@ -48,13 +49,23 @@ public struct InitializerDecl: CodeBlock, Sendable { var effectSpecifiers: FunctionEffectSpecifiersSyntax? if isAsync || isThrowing { effectSpecifiers = FunctionEffectSpecifiersSyntax( - asyncSpecifier: isAsync - ? .keyword(.async, leadingTrivia: .space, trailingTrivia: .space) - : nil, - throwsSpecifier: isThrowing ? .keyword(.throws, leadingTrivia: .space) : nil + asyncSpecifier: isAsync ? .keyword(.async, leadingTrivia: .space) : nil, + throwsClause: isThrowing + ? ThrowsClauseSyntax(throwsSpecifier: .keyword(.throws, leadingTrivia: .space)) + : nil ) } + let parameterList = FunctionParameterListSyntax( + parameters.enumerated().compactMap { index, param in + FunctionParameterSyntax.create( + from: param, + attributes: AttributeListSyntax([]), + isLast: index >= parameters.count - 1 + ) + } + ) + let bodyBlock = CodeBlockSyntax( leftBrace: .leftBraceToken(leadingTrivia: .space, trailingTrivia: .newline), statements: CodeBlockItemListSyntax( @@ -79,7 +90,7 @@ public struct InitializerDecl: CodeBlock, Sendable { signature: FunctionSignatureSyntax( parameterClause: FunctionParameterClauseSyntax( leftParen: .leftParenToken(), - parameters: FunctionParameterListSyntax([]), + parameters: parameterList, rightParen: .rightParenToken() ), effectSpecifiers: effectSpecifiers @@ -88,9 +99,22 @@ public struct InitializerDecl: CodeBlock, Sendable { ) } - /// Creates an `init` declaration. + /// Creates an `init` declaration with no parameters. /// - Parameter content: A ``CodeBlockBuilder`` that provides the body of the initializer. public init(@CodeBlockBuilderResult _ content: () throws -> [any CodeBlock]) rethrows { + self.parameters = [] + self.body = try content() + } + + /// Creates an `init` declaration with parameters. + /// - Parameters: + /// - params: A ``ParameterBuilderResult`` that provides the initializer parameters. + /// - content: A ``CodeBlockBuilder`` that provides the body of the initializer. + public init( + @ParameterBuilderResult _ params: () -> [Parameter], + @CodeBlockBuilderResult _ content: () throws -> [any CodeBlock] + ) rethrows { + self.parameters = params() self.body = try content() } diff --git a/Sources/SyntaxKit/Declarations/Protocol.swift b/Sources/SyntaxKit/Declarations/Protocol.swift index dffec6c..0cda4a4 100644 --- a/Sources/SyntaxKit/Declarations/Protocol.swift +++ b/Sources/SyntaxKit/Declarations/Protocol.swift @@ -136,7 +136,7 @@ public struct Protocol: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Declarations/Struct.swift b/Sources/SyntaxKit/Declarations/Struct.swift index ec9fdee..2d7366e 100644 --- a/Sources/SyntaxKit/Declarations/Struct.swift +++ b/Sources/SyntaxKit/Declarations/Struct.swift @@ -70,7 +70,7 @@ public struct Struct: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Functions/Function+Syntax.swift b/Sources/SyntaxKit/Functions/Function+Syntax.swift index 7ab9bd4..81f7813 100644 --- a/Sources/SyntaxKit/Functions/Function+Syntax.swift +++ b/Sources/SyntaxKit/Functions/Function+Syntax.swift @@ -138,7 +138,7 @@ extension Function { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( diff --git a/Sources/SyntaxKit/Utilities/AttributeArgument.swift b/Sources/SyntaxKit/Utilities/ExprSyntax+AttributeArgument.swift similarity index 56% rename from Sources/SyntaxKit/Utilities/AttributeArgument.swift rename to Sources/SyntaxKit/Utilities/ExprSyntax+AttributeArgument.swift index dbf97e8..1f3cf0a 100644 --- a/Sources/SyntaxKit/Utilities/AttributeArgument.swift +++ b/Sources/SyntaxKit/Utilities/ExprSyntax+AttributeArgument.swift @@ -1,5 +1,5 @@ // -// AttributeArgument.swift +// ExprSyntax+AttributeArgument.swift // SyntaxKit // // Created by Leo Dion. @@ -29,23 +29,26 @@ import SwiftSyntax -/// Builds an expression syntax node from an attribute argument string. -/// - Parameter argument: If surrounded by double-quotes, produces a string literal expression; -/// otherwise produces an identifier reference expression. -/// - Returns: An `ExprSyntax` representing the argument. -internal func buildAttributeArgumentExpr(from argument: String) -> ExprSyntax { - if argument.hasPrefix("\"") && argument.hasSuffix("\"") && argument.count >= 2 { - let content = String(argument.dropFirst().dropLast()) - return ExprSyntax( - StringLiteralExprSyntax( - openingQuote: .stringQuoteToken(), - segments: StringLiteralSegmentListSyntax([ - .stringSegment(StringSegmentSyntax(content: .stringSegment(content))) - ]), - closingQuote: .stringQuoteToken() +extension ExprSyntax { + /// Creates an expression from an attribute-argument string. + /// + /// A double-quoted value becomes a string literal expression; anything else + /// becomes an identifier reference expression. + /// - Parameter argument: The raw attribute-argument text. + internal init(attributeArgument argument: String) { + if argument.hasPrefix("\"") && argument.hasSuffix("\"") && argument.count >= 2 { + let content = String(argument.dropFirst().dropLast()) + self = ExprSyntax( + StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: StringLiteralSegmentListSyntax([ + .stringSegment(StringSegmentSyntax(content: .stringSegment(content))) + ]), + closingQuote: .stringQuoteToken() + ) ) - ) - } else { - return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(argument))) + } else { + self = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(argument))) + } } } diff --git a/Sources/SyntaxKit/Variables/Variable+Attributes.swift b/Sources/SyntaxKit/Variables/Variable+Attributes.swift index bc2511d..b35f39b 100644 --- a/Sources/SyntaxKit/Variables/Variable+Attributes.swift +++ b/Sources/SyntaxKit/Variables/Variable+Attributes.swift @@ -68,7 +68,7 @@ extension Variable { let rightParen: TokenSyntax = .rightParenToken() let argumentList = arguments.map { argument in - buildAttributeArgumentExpr(from: argument) + ExprSyntax(attributeArgument: argument) } let argumentsSyntax = AttributeSyntax.Arguments.argumentList( diff --git a/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift index bb85669..0c5e488 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift @@ -100,4 +100,67 @@ internal struct InitializerDeclTests { let normalizedExpected = expected.normalize() #expect(normalizedGenerated == normalizedExpected) } + + @Test internal func testAsyncThrowingInit() { + let initDecl = InitializerDecl {}.async().throwing() + + // Fix 2 regression: async and throws must be single-spaced, not "async throws". + let generated = initDecl.syntax.description + #expect(generated.contains("async throws")) + #expect(!generated.contains("async throws")) + + let expected = """ + init() async throws { + } + """ + #expect(initDecl.generateCode().normalize() == expected.normalize()) + } + + @Test internal func testInitWithParameters() { + let initDecl = InitializerDecl { + Parameter(name: "name", type: "String") + Parameter(name: "age", type: "Int") + } _: { + Call("print") { + ParameterExp(unlabeled: Literal.string("hi")) + } + } + + let expected = """ + init(name: String, age: Int) { + print("hi") + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testInitWithParameterDefault() { + let initDecl = InitializerDecl { + Parameter(name: "count", type: "Int", defaultValue: "0") + } _: { + } + + let generated = initDecl.generateCode().normalize() + #expect(generated.contains("count: Int = 0")) + } + + @Test internal func testPublicInitWithParameters() { + let initDecl = InitializerDecl { + Parameter(name: "value", type: "String") + } _: { + } + .access(.public) + + let expected = """ + public init(value: String) { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } }