diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e1e8a..462c578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,10 @@ jobs: matrix: platform: - iOS + - mac-catalyst - macOS - tvOS + - visionOS - watchOS swift: - "6.0" @@ -39,14 +41,20 @@ jobs: with: swift: ~${{ matrix.swift }} action: none + verbosity: xcbeautify - - if: matrix.platform != 'macOS' + - if: matrix.platform != 'mac-catalyst' && matrix.platform != 'macOS' name: Download Required Runtime run: xcodebuild -downloadPlatform ${{ matrix.platform }} - - uses: mxcl/xcodebuild@v3 + - if: matrix.platform != 'mac-catalyst' && matrix.platform != 'macOS' + name: List Available Simulators + run: xcrun simctl list devices + + - name: Run Tests + uses: mxcl/xcodebuild@v3 with: swift: ~${{ matrix.swift }} platform: ${{ matrix.platform }} - scheme: TextBuilder action: test + verbosity: xcbeautify diff --git a/.gitignore b/.gitignore index 4732bb3..19419b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /*.xcodeproj xcuserdata/ DerivedData/ +*.xcresult diff --git a/Package.resolved b/Package.resolved index 51637e6..1ba697e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "251934ce5c5705f19a56210b5be6599d0af09ebf03410d86b3ecd96529565ab0", + "originHash" : "6bf03f7044d091382182b311d6725fe9077db90c0787cf45352c650dc4f8efd2", "pins" : [ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", - "version" : "1.2.2" + "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", + "version" : "1.6.1" } }, { @@ -33,8 +33,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", - "version" : "1.0.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "9ab11325daa51c7c5c10fcf16c92bac906717c7e", + "version" : "0.6.4" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } }, { @@ -42,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version" : "1.0.2" + "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", + "version" : "1.6.1" } } ], diff --git a/Package.swift b/Package.swift index 6973b1b..8d7b948 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ -// swift-tools-version:6.0 +// swift-tools-version: 6.0 +import CompilerPluginSupport import PackageDescription let package = Package( @@ -16,12 +17,23 @@ let package = Package( targets: [ .target(name: "TextBuilder", dependencies: [ .product(name: "Builders", package: "swift-builders"), + "TextBuilderMacro", ]), .testTarget(name: "TextBuilderTests", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), .target(name: "TextBuilder"), ]), + .macro(name: "TextBuilderMacro", dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ]), + .testTarget(name: "TextBuilderMacroTests", dependencies: [ + "TextBuilderMacro", + .product(name: "MacroTesting", package: "swift-macro-testing"), + ]), + .executableTarget(name: "Benchmarks", dependencies: [ .product(name: "Benchmark", package: "swift-benchmark"), .target(name: "TextBuilder"), @@ -33,4 +45,15 @@ package.dependencies = [ .package(url: "https://github.com/google/swift-benchmark", from: "0.1.2"), .package(url: "https://github.com/davdroman/swift-builders", from: "0.10.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), + + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.6.0"), + .package(url: "https://github.com/swiftlang/swift-syntax", "600.0.0"..<"603.0.0"), ] + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings? += [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] +} diff --git a/Sources/Benchmarks/main.swift b/Sources/Benchmarks/main.swift index 1eabd99..50430ec 100644 --- a/Sources/Benchmarks/main.swift +++ b/Sources/Benchmarks/main.swift @@ -3,7 +3,7 @@ import SwiftUI import TextBuilder benchmark("Result Builder") { - @TextBuilderWithSpaces + @TextBuilder(separator: " ") func complexTextBuilderText() -> Text { "Lorem".text.underline().foregroundColor(.blue) if false { diff --git a/Sources/TextBuilder/TextBuilder.swift b/Sources/TextBuilder/TextBuilder.swift index 834cb39..ad3d476 100644 --- a/Sources/TextBuilder/TextBuilder.swift +++ b/Sources/TextBuilder/TextBuilder.swift @@ -1,81 +1,45 @@ -import SwiftUI +public import SwiftUI -/// A custom attribute that constructs combined text views. +/// A body macro that lets you build a single `SwiftUI.Text` from multiple parts using a builder-style closure. Write +/// `Text` fragments and string-like values across multiple lines, and they will be concatenated into one `Text`, +/// optionally inserting a separator between items. /// -/// You can use ``TextBuilder`` as an attribute for text-producing properties -/// or function parameters, allowing them to provide combined text views. For example, -/// the following `loremIpsum` property will create a single styled text view with each -/// text separated using eggplant emoji. +/// ## Behavior +/// - Empty bodies produce `Text(verbatim: "")`. +/// - You can mix `Text` values and string-like values (e.g., string literals, `String`, results of `String(i)`, etc.). +/// These string values are treated as verbatim text. +/// - Control flow is supported: `if`/`else`, `if let`, and `for` loops can conditionally emit or repeat parts. +/// - Modifiers applied to individual segments (e.g., `.bold()`, `.underline()`, `.foregroundColor(_:)`) are preserved +/// for those segments. +/// - When a separator is provided, it is inserted between emitted segments, not before the first or after the last one. /// -/// struct EggplantSeparator: TextBuilderSeparator { -/// static var separator: String? { " πŸ† " } -/// } +/// ## Example +/// ```swift +/// @TextBuilder(separator: " ") +/// func message() -> Text { +/// Text("Hello").bold() +/// "world, the year is" +/// String(2025) +/// } +/// ``` /// -/// @TextBuilder -/// var loremIpsum: Text { -/// Text("Lorem").underline().foregroundColor(.blue) -/// Text("ipsum dolor") -/// Text("sit").bold() -/// Text("amet, consectetur") -/// } -/// -@resultBuilder -public struct TextBuilderWith { - @inlinable - public static func buildPartialBlock(first: Text?) -> Text? { - first - } - - @inlinable - public static func buildPartialBlock(accumulated: Text?, next: Text?) -> Text? { - guard let next else { - return accumulated - } - guard let accumulated else { - return next - } - guard let separator = Separator.separator else { - return accumulated + next - } - return accumulated + Text(separator) + next - } - - public static func buildArray(_ components: [Text?]) -> Text? { - components.lazy.compactMap { $0 }.joined(separator: Separator.separator.map { Text($0) }) - } - - @inlinable - public static func buildEither(first component: Text?) -> Text? { - component - } - - @inlinable - public static func buildEither(second component: Text?) -> Text? { - component - } - - @inlinable - public static func buildExpression(_ string: some StringProtocol) -> Text? { - Text(string) - } - - @inlinable - public static func buildExpression(_ component: Text) -> Text? { - component - } - - @inlinable - public static func buildLimitedAvailability(_ component: Text?) -> Text? { - component - } - - @inlinable - public static func buildOptional(_ component: Text??) -> Text? { - component ?? nil - } +/// ## Notes +/// - The expansion relies on the ``Text.init(separator:default:content:)-(Text?,_,_)`` builder-style API that is +/// included as part of this package, which performs concatenation and separator insertion. +/// - Apply `@TextBuilder` to functions that return `Text`. +/// - Applying `@TextBuilder` to computed properties that return `Text` is currently unsupported due to limitations in +/// Swift. See https://github.com/swiftlang/swift/issues/75715 for details. +@attached(body) +public macro TextBuilder(separator: Text? = nil) = #externalMacro( + module: "TextBuilderMacro", + type: "TextBuilderMacro" +) + +/// Convenience overload that accepts any `StringProtocol` as the separator, which is treated as verbatim text +/// (equivalent to `Text(verbatim: separator)`). +@attached(body) +public macro TextBuilder(separator: Separator) = #externalMacro( + module: "TextBuilderMacro", + type: "TextBuilderMacro" +) - @inlinable - public static func buildFinalResult(_ component: Text?) -> Text { - component ?? .empty - } -} diff --git a/Sources/TextBuilder/TextBuilderSeparator.swift b/Sources/TextBuilder/TextBuilderSeparator.swift deleted file mode 100644 index 0d2cc92..0000000 --- a/Sources/TextBuilder/TextBuilderSeparator.swift +++ /dev/null @@ -1,20 +0,0 @@ -public protocol TextBuilderSeparator { - associatedtype Separator: StringProtocol - static var separator: Separator? { get } -} - -public struct NoSeparator: TextBuilderSeparator { - public static var separator: String? { nil } -} - -public struct WhitespaceSeparator: TextBuilderSeparator { - public static var separator: String? { " " } -} - -public struct NewlineSeparator: TextBuilderSeparator { - public static var separator: String? { "\n" } -} - -public typealias TextBuilder = TextBuilderWith -public typealias TextBuilderWithSpaces = TextBuilderWith -public typealias TextBuilderWithNewlines = TextBuilderWith diff --git a/Sources/TextBuilder/TextExtensions.swift b/Sources/TextBuilder/TextExtensions.swift index 3b07909..6994668 100644 --- a/Sources/TextBuilder/TextExtensions.swift +++ b/Sources/TextBuilder/TextExtensions.swift @@ -1,21 +1,31 @@ -import Builders -import SwiftUI - -extension Text { - @inlinable - public static var empty: Text { - Text(verbatim: "") - } -} +public import Builders +public import SwiftUI extension StringProtocol { + /// Turns any `StringProtocol` (like `String` or `Substring`) into a SwiftUI `Text`. + /// + /// In this package, it’s a small convenience for the moments when you actually need a `Text`: + /// to chain `Text`-only modifiers or to pass a `Text` to an API, even though inside builders + /// you can drop raw strings directly. Using `.text` keeps things tidy and avoids sprinkling + /// `Text(...)` everywhere. + /// + /// ## Example + /// ```swift + /// @TextBuilder(separator: " ") + /// func message() -> Text { + /// "Hello" + /// "world".text.bold() + /// } + /// ``` + /// + /// This is equivalent to `Text(self)` and produces verbatim text. @inlinable public var text: Text { Text(self) } } -extension Sequence where Element == Text { +extension Sequence { /// Returns a new `Text` by concatenating the elements of the sequence. /// /// If the sequence is empty, it returns nil. @@ -29,13 +39,14 @@ extension Sequence where Element == Text { /// /// - Parameter separator: A `Text` view to insert between each of the elements /// in this sequence. By default there is no separator. - /// - Returns: A single, concatenated `Text` view. + /// - Returns: A single, concatenated `Text` view, or `nil` if the sequence + /// is empty. public func joined(separator: Text? = nil) -> Text? { reduce(nil) { accumulated, next in - guard let accumulated = accumulated else { + guard let accumulated else { return next } - guard let separator = separator else { + guard let separator else { return accumulated + next } return accumulated + separator + next @@ -43,11 +54,9 @@ extension Sequence where Element == Text { } } -public typealias TextArrayBuilder = ArrayBuilder - -extension TextArrayBuilder { +extension ArrayBuilder { @inlinable - public static func buildExpression(_ expression: S) -> [Text] { + public static func buildExpression(_ expression: some StringProtocol) -> [Text] { [Text(expression)] } } @@ -62,8 +71,8 @@ extension Text { /// - content: A text array builder that creates text components. public init( separator: Text? = nil, - default: Text = .empty, - @TextArrayBuilder content: () -> [Text] + default: Text = Text(verbatim: ""), + @ArrayBuilder content: () -> [Text] ) { self = content().joined(separator: separator) ?? `default` } @@ -75,10 +84,10 @@ extension Text { /// - separator: The string to use as a separator between received text components. /// - content: A text array builder that creates text components. @inlinable - public init( - separator: Separator, - default: Text = .empty, - @TextArrayBuilder content: () -> [Text] + public init( + separator: some StringProtocol, + default: Text = Text(verbatim: ""), + @ArrayBuilder content: () -> [Text] ) { self.init(separator: Text(separator), content: content) } diff --git a/Sources/TextBuilderMacro/TextBuilderMacro.swift b/Sources/TextBuilderMacro/TextBuilderMacro.swift new file mode 100644 index 0000000..dfe9c15 --- /dev/null +++ b/Sources/TextBuilderMacro/TextBuilderMacro.swift @@ -0,0 +1,25 @@ +import SwiftDiagnostics +public import SwiftSyntax +public import SwiftSyntaxMacros + +public struct TextBuilderMacro: BodyMacro { + public static func expansion( + of node: AttributeSyntax, + providingBodyFor declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax, + in context: some MacroExpansionContext + ) throws -> [CodeBlockItemSyntax] { + let separatorExpr: ExprSyntax = if let args = node.arguments, case let .argumentList(list) = args, let first = list.first { + first.expression + } else { + ExprSyntax(NilLiteralExprSyntax()) + } + + let statements = declaration.body?.statements ?? [] + + return [ + """ + Text(separator: \(separatorExpr)) { \(statements) } + """ + ] + } +} diff --git a/Sources/TextBuilderMacro/TextBuilderMacroPlugin.swift b/Sources/TextBuilderMacro/TextBuilderMacroPlugin.swift new file mode 100644 index 0000000..ee6eecc --- /dev/null +++ b/Sources/TextBuilderMacro/TextBuilderMacroPlugin.swift @@ -0,0 +1,9 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct TextBuilderMacroPlugin: CompilerPlugin { + let providingMacros: [any Macro.Type] = [ + TextBuilderMacro.self, + ] +} diff --git a/Tests/TextBuilderMacroTests/TextBuilderMacroTests.swift b/Tests/TextBuilderMacroTests/TextBuilderMacroTests.swift new file mode 100644 index 0000000..2710d62 --- /dev/null +++ b/Tests/TextBuilderMacroTests/TextBuilderMacroTests.swift @@ -0,0 +1,368 @@ +#if canImport(TextBuilderMacro) +import MacroTesting +import Testing +import TextBuilderMacro + +@Suite( + .macros( + [TextBuilderMacro.self], + indentationWidth: .tab, + record: .missing + ) +) +struct TextBuilderMacroTests { + @Test( + .disabled( + """ + Body Macros aren't compatible with computed properties yet. + + See: https://github.com/swiftlang/swift/issues/75715 + """ + ) + ) + func computedVarWithoutImplementation() throws { + assertMacro { + """ + @TextBuilder + var text: Text + """ + } expansion: { + """ + var text: Text { + Text(separator: nil) { + } + } + """ + } + } + + @Test( + .disabled( + """ + Body Macros aren't compatible with computed properties yet. + + See: https://github.com/swiftlang/swift/issues/75715 + """ + ) + ) + func computedVarWithoutSeparator() throws { + assertMacro { + """ + @TextBuilder + var text: Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + """ + } expansion: { + """ + var text: Text { + Text(separator: nil) { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1 ... 3 { + String(i) + } + } + } + """ + } + } + + @Test( + .disabled( + """ + Body Macros aren't compatible with computed properties yet. + + See: https://github.com/swiftlang/swift/issues/75715 + """ + ) + ) + func computedVarWithLiteralSeparator() throws { + assertMacro { + """ + @TextBuilder(separator: " ") + var text: Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + """ + } expansion: { + """ + var text: Text { + Text(separator: " ") { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1 ... 3 { + String(i) + } + } + } + """ + } + } + + @Test( + .disabled( + """ + Body Macros aren't compatible with computed properties yet. + + See: https://github.com/swiftlang/swift/issues/75715 + """ + ) + ) + func computedVarWithNonLiteralSeparator() throws { + assertMacro { + """ + let separator = " " + @TextBuilder(separator: separator) + var text: Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + """ + } expansion: { + """ + let separator = " " + var text: Text { + Text(separator: separator) { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1 ... 3 { + String(i) + } + } + } + """ + } + } + + @Test func functionWithoutImplementation() throws { + assertMacro { + """ + @TextBuilder + func text() -> Text + """ + } expansion: { + """ + func text() -> Text { + Text(separator: nil) { + } + } + """ + } + } + + @Test func functionWithoutSeparator() throws { + assertMacro { + """ + @TextBuilder + func text() -> Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + """ + } expansion: { + """ + func text() -> Text { + Text(separator: nil) { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1 ... 3 { + String(i) + } + } + } + """ + } + } + + @Test func functionWithLiteralSeparator() throws { + assertMacro { + """ + @TextBuilder(separator: " ") + func text() -> Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + """ + } expansion: { + """ + func text() -> Text { + Text(separator: " ") { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1 ... 3 { + String(i) + } + } + } + """ + } + } + + @Test func functionWithNonLiteralSeparator() throws { + assertMacro { + """ + let separator = " " + @TextBuilder(separator: separator) + func text() -> Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + """ + } expansion: { + """ + let separator = " " + func text() -> Text { + Text(separator: separator) { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1 ... 3 { + String(i) + } + } + } + """ + } + } +} +#endif diff --git a/Tests/TextBuilderTests/TextBuilderTests.swift b/Tests/TextBuilderTests/TextBuilderTests.swift index 0f6fa74..3cf2ebb 100644 --- a/Tests/TextBuilderTests/TextBuilderTests.swift +++ b/Tests/TextBuilderTests/TextBuilderTests.swift @@ -5,9 +5,29 @@ import TextBuilder @Suite struct TextBuilderTests { - @Test func basicTextBuilder() { + @Test func empty() { + @TextBuilder + func sut() -> Text { + // empty + } + + expectNoDifference( + sut(), + Text(verbatim: "") + ) + } + + @Test func defaultTextBuilder() { + @TextBuilder + func sut() -> Text { + Text("Lorem").underline().foregroundColor(.blue) + Text("ipsum dolor") + Text("sit").bold() + Text("amet, consectetur") + } + expectNoDifference( - basicTextBuilderText(), + sut(), Text("Lorem").underline().foregroundColor(.blue) + Text("ipsum dolor") + Text("sit").bold() + @@ -16,8 +36,16 @@ struct TextBuilderTests { } @Test func spacedTextBuilder() { + @TextBuilder(separator: " ") + func sut() -> Text { + Text("Lorem").underline().foregroundColor(.blue) + Text("ipsum dolor") + Text("sit").bold() + Text("amet, consectetur") + } + expectNoDifference( - spacedTextBuilderText(), + sut(), Text("Lorem").underline().foregroundColor(.blue) + Text(verbatim: " ") + Text("ipsum dolor") + @@ -29,8 +57,16 @@ struct TextBuilderTests { } @Test func multilineTextBuilder() { + @TextBuilder(separator: "\n") + func sut() -> Text { + Text("Lorem").underline().foregroundColor(.blue) + Text("ipsum dolor") + Text("sit").bold() + Text("amet, consectetur") + } + expectNoDifference( - multilineTextBuilderText(), + sut(), Text("Lorem").underline().foregroundColor(.blue) + Text(verbatim: "\n") + Text("ipsum dolor") + @@ -42,91 +78,59 @@ struct TextBuilderTests { } @Test func customTextBuilder() { + @TextBuilder(separator: " πŸ‘ ") + func sut() -> Text { + Text("Lorem").underline().foregroundColor(.blue) + Text("ipsum dolor") + Text("sit").bold() + Text("amet, consectetur") + } + expectNoDifference( - customTextBuilderText(), + sut(), Text("Lorem").underline().foregroundColor(.blue) + - Text(verbatim: " πŸ† ") + + Text(verbatim: " πŸ‘ ") + Text("ipsum dolor") + - Text(verbatim: " πŸ† ") + + Text(verbatim: " πŸ‘ ") + Text("sit").bold() + - Text(verbatim: " πŸ† ") + + Text(verbatim: " πŸ‘ ") + Text("amet, consectetur") ) } @Test func complexTextBuilder() { + @TextBuilder(separator: " ") + func sut() -> Text { + "Lorem".text.underline().foregroundColor(.blue) + if false { + "ipsum dolor" + } + if false { + "sit" + } else { + "sit".text.bold() + } + if let string = "amet, consectetur" as String? { + string + } + for i in 1...3 { + String(i) + } + } + expectNoDifference( - complexTextBuilderText(), + sut(), Text(verbatim: "Lorem").underline().foregroundColor(.blue) + Text(verbatim: " ") + Text(verbatim: "sit").bold() + Text(verbatim: " ") + Text(verbatim: "amet, consectetur") + Text(verbatim: " ") + - ( - Text(verbatim: "1") + - Text(verbatim: " ") + - Text(verbatim: "2") + - Text(verbatim: " ") + - Text(verbatim: "3") - ) + Text(verbatim: "1") + + Text(verbatim: " ") + + Text(verbatim: "2") + + Text(verbatim: " ") + + Text(verbatim: "3") ) } } - -private extension TextBuilderTests { - @TextBuilder - func basicTextBuilderText() -> Text { - Text("Lorem").underline().foregroundColor(.blue) - Text("ipsum dolor") - Text("sit").bold() - Text("amet, consectetur") - } - - @TextBuilderWithSpaces - func spacedTextBuilderText() -> Text { - Text("Lorem").underline().foregroundColor(.blue) - Text("ipsum dolor") - Text("sit").bold() - Text("amet, consectetur") - } - - @TextBuilderWithNewlines - func multilineTextBuilderText() -> Text { - Text("Lorem").underline().foregroundColor(.blue) - Text("ipsum dolor") - Text("sit").bold() - Text("amet, consectetur") - } - - struct EmojiSeparator: TextBuilderSeparator { - static var separator: String? { " πŸ† " } - } - - @TextBuilderWith - func customTextBuilderText() -> Text { - Text("Lorem").underline().foregroundColor(.blue) - Text("ipsum dolor") - Text("sit").bold() - Text("amet, consectetur") - } - - @TextBuilderWithSpaces - func complexTextBuilderText() -> Text { - "Lorem".text.underline().foregroundColor(.blue) - if false { - "ipsum dolor" - } - if false { - "sit" - } else { - "sit".text.bold() - } - if let string = "amet, consectetur" as String? { - string - } - for i in 1...3 { - String(i) - } - } -} diff --git a/Tests/TextBuilderTests/TextExtensionsTests.swift b/Tests/TextBuilderTests/TextExtensionsTests.swift index 25aa5bb..f4b528d 100644 --- a/Tests/TextBuilderTests/TextExtensionsTests.swift +++ b/Tests/TextBuilderTests/TextExtensionsTests.swift @@ -1,3 +1,4 @@ +import Builders import CustomDump import SwiftUI import Testing @@ -137,7 +138,7 @@ private extension TextExtensionsTests { ] } - @TextArrayBuilder + @ArrayBuilder func textArrayBuilderText() -> [Text] { Text("Lorem").underline().foregroundColor(.blue) Text("ipsum dolor") diff --git a/TextBuilder.playground/Contents.swift b/TextBuilder.playground/Contents.swift deleted file mode 100644 index 52257ea..0000000 --- a/TextBuilder.playground/Contents.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI -import TextBuilder -import PlaygroundSupport - -struct DemoView: View { - let name: String? - - var body: some View { - VStack(spacing: 20) { - welcomeText.font(.title) - descriptionText - someClickableText - footnote - } - .multilineTextAlignment(.center) - .padding() - .background(Color(.windowBackgroundColor)) - } - - @TextBuilderWithSpaces - var welcomeText: Text { - if let name = name, !name.isEmpty { - "Welcome," - name.text.bold() - } else { - "Welcome" - } - } - - @TextBuilder - var descriptionText: Text { - "This is a demo of " - "TextBuilder".text.italic() - "." - } - - var someClickableText: Text { - Text(separator: " ") { - "This paragraph is implemented" - "without".text.underline() - "a builder to showcase the" - "Text.init(separator:content:)".text.font(.system(.body, design: .monospaced)).foregroundColor(.gray) - "initializer provided by this library." - } - } - - var footnote: some View { - Text { - "Made by " - "@davdroman".text.bold() - } - .underline() - .font(.system(.caption)) - .foregroundColor(.blue) - .pointable() - .onTapGesture { NSWorkspace.shared.open(URL(string: "https://github.com/davdroman")!) } - } -} - -extension View { - func pointable() -> some View { - onHover { inside in - if inside { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } -} - -let view = NSHostingController(rootView: DemoView(name: NSFullUserName())) -view.view.frame.size.width = 340 -PlaygroundPage.current.needsIndefiniteExecution = true -PlaygroundPage.current.liveView = view diff --git a/TextBuilder.playground/contents.xcplayground b/TextBuilder.playground/contents.xcplayground deleted file mode 100644 index 1c968e7..0000000 --- a/TextBuilder.playground/contents.xcplayground +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/TextBuilder.playground/playground.xcworkspace/contents.xcworkspacedata b/TextBuilder.playground/playground.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/TextBuilder.playground/playground.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/TextBuilder.playground/timeline.xctimeline b/TextBuilder.playground/timeline.xctimeline deleted file mode 100644 index bf468af..0000000 --- a/TextBuilder.playground/timeline.xctimeline +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/TextBuilder.xcworkspace/contents.xcworkspacedata b/TextBuilder.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index ca3329e..0000000 --- a/TextBuilder.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/TextBuilder.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/TextBuilder.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/TextBuilder.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/TextBuilder.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TextBuilder.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index d73c60f..0000000 --- a/TextBuilder.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,51 +0,0 @@ -{ - "originHash" : "251934ce5c5705f19a56210b5be6599d0af09ebf03410d86b3ecd96529565ab0", - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" - } - }, - { - "identity" : "swift-benchmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/swift-benchmark", - "state" : { - "revision" : "8163295f6fe82356b0bcf8e1ab991645de17d096", - "version" : "0.1.2" - } - }, - { - "identity" : "swift-builders", - "kind" : "remoteSourceControl", - "location" : "https://github.com/davdroman/swift-builders", - "state" : { - "revision" : "72cfbfffd6a56883a3c590a87f88a71c819d6de3", - "version" : "0.10.0" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", - "version" : "1.6.1" - } - } - ], - "version" : 3 -} diff --git a/TextBuilder.xcworkspace/xcshareddata/xcschemes/Benchmarks.xcscheme b/TextBuilder.xcworkspace/xcshareddata/xcschemes/Benchmarks.xcscheme deleted file mode 100644 index b7cb27d..0000000 --- a/TextBuilder.xcworkspace/xcshareddata/xcschemes/Benchmarks.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/TextBuilder.xcworkspace/xcshareddata/xcschemes/TextBuilder.xcscheme b/TextBuilder.xcworkspace/xcshareddata/xcschemes/TextBuilder.xcscheme deleted file mode 100644 index 032f27a..0000000 --- a/TextBuilder.xcworkspace/xcshareddata/xcschemes/TextBuilder.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -