From d6808555bc978065bb2965823fc4d12f9dda742f Mon Sep 17 00:00:00 2001 From: leogdion Date: Thu, 2 Apr 2026 08:16:38 -0400 Subject: [PATCH] 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 430facb..9f0bed5 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 cb7e12b..1dae40b 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 a9faf08..0988be5 100644 --- a/Sources/SyntaxKit/Declarations/Extension.swift +++ b/Sources/SyntaxKit/Declarations/Extension.swift @@ -130,13 +130,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)) } @@ -154,6 +154,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 7448c53..0afbf4b 100644 --- a/Sources/SyntaxKit/Declarations/Import.swift +++ b/Sources/SyntaxKit/Declarations/Import.swift @@ -100,13 +100,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)) } @@ -124,6 +124,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 93392a5..b9911d5 100644 --- a/Sources/SyntaxKit/Declarations/Protocol.swift +++ b/Sources/SyntaxKit/Declarations/Protocol.swift @@ -135,13 +135,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)) } @@ -159,6 +159,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 8ba953b..f9c57c5 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 cb1a734..f9fd6c8 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 78fc29e..c4340e3 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)) ] ) @@ -135,13 +140,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, @@ -162,6 +167,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 3901f3f..199334b 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 5f44527..84ee586 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 432fdbd..7e2b539 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 1cc8ff8..a77d3d0 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift @@ -133,6 +133,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 572dd72..94bf513 100644 --- a/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift +++ b/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift @@ -58,6 +58,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",