Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ jobs:
matrix:
platform:
- iOS
- mac-catalyst
- macOS
- tvOS
- visionOS
- watchOS
swift:
- "6.0"
Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
/*.xcodeproj
xcuserdata/
DerivedData/
*.xcresult
41 changes: 34 additions & 7 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 24 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// swift-tools-version:6.0
// swift-tools-version: 6.0

import CompilerPluginSupport
import PackageDescription

let package = Package(
Expand All @@ -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"),
Expand All @@ -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"),
]
}
2 changes: 1 addition & 1 deletion Sources/Benchmarks/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import SwiftUI
import TextBuilder

benchmark("Result Builder") {
@TextBuilderWithSpaces
@TextBuilder(separator: " ")
func complexTextBuilderText() -> Text {
"Lorem".text.underline().foregroundColor(.blue)
if false {
Expand Down
116 changes: 40 additions & 76 deletions Sources/TextBuilder/TextBuilder.swift
Original file line number Diff line number Diff line change
@@ -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<EggplantSeparator>
/// var loremIpsum: Text {
/// Text("Lorem").underline().foregroundColor(.blue)
/// Text("ipsum dolor")
/// Text("sit").bold()
/// Text("amet, consectetur")
/// }
///
@resultBuilder
public struct TextBuilderWith<Separator: TextBuilderSeparator> {
@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: StringProtocol>(separator: Separator) = #externalMacro(
module: "TextBuilderMacro",
type: "TextBuilderMacro"
)

@inlinable
public static func buildFinalResult(_ component: Text?) -> Text {
component ?? .empty
}
}
20 changes: 0 additions & 20 deletions Sources/TextBuilder/TextBuilderSeparator.swift

This file was deleted.

Loading