From 86c360f0d518eceb6b51c9abc3d986ac8be31c6b Mon Sep 17 00:00:00 2001 From: Varun Santhanam Date: Mon, 25 May 2026 16:03:00 +0100 Subject: [PATCH] Document the `StringEnvironment` API --- README.md | 2 +- .../Articles/EnvironmentValues.md | 117 +++++++++++++++ .../Calligraphy.docc/Calligraphy.md | 15 +- .../StringComposition/ArrayExtensions.swift | 59 ++++++++ .../Calligraphy/StringComposition/Entry.swift | 13 +- .../StringComposition/Joined.swift | 2 +- .../StringComposition/LineSpacing.swift | 2 +- .../StringComposition/PrefixLines.swift | 4 +- .../StringComposition/QuotationMark.swift | 15 +- .../QuotationMarkStyle.swift | 2 +- .../Calligraphy/StringComposition/Quote.swift | 69 +++++++-- .../StringComposition/StringBuilder.swift | 10 +- .../StringComposition/TabDefinition.swift | 2 +- .../StringEntryMacro.swift | 26 +++- .../StringEntryMacroTests.swift | 142 +++++++++++++++++- .../StringComposition/EntryTests.swift | 26 ++++ .../EnvironmentModifierTests.swift | 10 +- .../QuotationMarkTests.swift | 13 ++ .../StringComposition/QuoteTests.swift | 34 +++++ .../ReadEnvironmentTests.swift | 20 +-- .../StringComponentTests.swift | 10 +- .../StringEnvironmentTests.swift | 2 +- .../StringExtensionsTests.swift | 52 +++---- 23 files changed, 562 insertions(+), 85 deletions(-) create mode 100644 Sources/Calligraphy/Calligraphy.docc/Articles/EnvironmentValues.md create mode 100644 Sources/Calligraphy/StringComposition/ArrayExtensions.swift diff --git a/README.md b/README.md index 39e161a..bc5f262 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ let project = Folder("MyProject") { File("main.swift") { Line { "print(" - Quoted { + Quote { "Hello, World!" } ")" diff --git a/Sources/Calligraphy/Calligraphy.docc/Articles/EnvironmentValues.md b/Sources/Calligraphy/Calligraphy.docc/Articles/EnvironmentValues.md new file mode 100644 index 0000000..f60c96e --- /dev/null +++ b/Sources/Calligraphy/Calligraphy.docc/Articles/EnvironmentValues.md @@ -0,0 +1,117 @@ +# Using the String Environment + +@Metadata { + @PageKind(article) +} + +Read and write values that flow through a ``StringComponent`` tree. + +## Overview + +Every ``StringComponent`` is rendered with an instance of ``StringEnvironmentValues`` that flows from ancestor to descendant. This is the same pattern as SwiftUI's `EnvironmentValues`: a parent component can set a value, and any descendant can read it without the value being passed explicitly through initializers. + +Calligraphy itself uses the environment to configure built-in components — for example, ``Lines`` reads ``StringEnvironmentValues/lineSpacing`` to decide how many newlines to put between its children, and ``QuotationMark`` reads ``StringEnvironmentValues/quotationMarkStyle`` to choose its character. The same machinery is available to your own components. + +## Reading Values + +Inside a `StringComponent`, use the ``StringEnvironment`` property wrapper to read a value from the surrounding environment. The wrapper resolves lazily at render time, so its value is always current — and because the property is read inside `body`, you can branch on it normally: + +```swift +struct ListItem: StringComponent { + + @StringEnvironment(\.lineSpacing) + private var spacing: Int + + let text: String + + var body: some StringComponent { + if spacing > 1 { + "• \(text)" + } else { + "- \(text)" + } + } + +} +``` + +The property wrapper is populated by reflection on the enclosing `StringComponent`, so it only works as a stored property of a component. When you need to read the environment outside of that context — in a free `@StringBuilder` function, inside a `String.build { ... }` closure, or anywhere else — reach for ``ReadEnvironment`` instead: + +```swift +let component = ReadEnvironment { environment in + if environment.lineSpacing > 1 { + "spaced" + } else { + "tight" + } +} +``` + +## Writing Values + +Use the ``StringComponent/environment(_:_:)-(_,Value)`` modifier to set an environment value on a component and its descendants. Ancestor components are unaffected. + +```swift +let component = Lines { + "foo" + "bar" + "baz" +} +.environment(\.lineSpacing, 2) +``` + +When you need to set multiple values at once, or compute the new value from the current one, use ``StringComponent/transformEnvironment(_:)``: + +```swift +Lines { + "foo" + "bar" +} +.transformEnvironment { environment in + environment.lineSpacing = 2 + environment.tabDefinition = .spaces(4) +} +``` + +If two modifiers in the same chain write the same value, the one closest to the descendants wins, because it transforms the environment last. + +## Defining Custom Environment Values + +To expose your own environment value, extend ``StringEnvironmentValues`` and annotate a stored property with the ``StringEntry()`` macro. The macro synthesizes a private ``StringEnvironmentKey`` and the getter/setter accessors that read from and write to the environment storage. + +```swift +extension StringEnvironmentValues { + + @StringEntry + public var prefix: String = "•" + +} +``` + +The property's initial value becomes the default returned when no ancestor has set the value: + +```swift +struct ListItem: StringComponent { + + @StringEnvironment(\.prefix) + private var prefix: String + + let text: String + + var body: some StringComponent { + "\(prefix) \(text)" + } + +} +``` + +Optional types do not need an initial value — when omitted, the default is `nil`: + +```swift +extension StringEnvironmentValues { + + @StringEntry + public var caption: String? + +} +``` diff --git a/Sources/Calligraphy/Calligraphy.docc/Calligraphy.md b/Sources/Calligraphy/Calligraphy.docc/Calligraphy.md index 7e7b0b1..fe31283 100644 --- a/Sources/Calligraphy/Calligraphy.docc/Calligraphy.md +++ b/Sources/Calligraphy/Calligraphy.docc/Calligraphy.md @@ -24,8 +24,13 @@ Calligraphy's type-safe API and builder patterns make it ideal for code generati ## Topics +### Getting Started + +- + ### String Composition +- - ``StringComponent`` - ``StringBuilder`` - ``StringComponents`` @@ -42,9 +47,7 @@ Calligraphy's type-safe API and builder patterns make it ideal for code generati - ``Tab`` - ``RawStringComponent`` - ``AnyStringComponent`` - -### String Composition Environment - +- - ``ReadEnvironment`` - ``StringEnvironment`` - ``StringEnvironmentKey`` @@ -53,6 +56,7 @@ Calligraphy's type-safe API and builder patterns make it ideal for code generati ### Directory Composition +- - ``DirectoryContent`` - ``DirectoryContentBuilder`` - ``Directory`` @@ -77,8 +81,3 @@ Calligraphy's type-safe API and builder patterns make it ideal for code generati - ``EmptyDataComponent`` - ``AnyDataComponent`` -### Articles - -- -- -- diff --git a/Sources/Calligraphy/StringComposition/ArrayExtensions.swift b/Sources/Calligraphy/StringComposition/ArrayExtensions.swift new file mode 100644 index 0000000..fb7e914 --- /dev/null +++ b/Sources/Calligraphy/StringComposition/ArrayExtensions.swift @@ -0,0 +1,59 @@ +// Calligraphy +// ArrayExtensions.swift +// +// MIT License +// +// Copyright (c) 2026 Varun Santhanam +// 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. + +@available(macOS 14.0, macCatalyst 17.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +extension Array { + + /// Transform each element of this array into a ``StringComponent`` and combine the results. + /// + /// Use this overload to embed an array directly into a `@StringBuilder` block. Because the closure is itself a `@StringBuilder`, the full DSL — including conditionals and nested control flow — is available when shaping each element's contribution. + /// + /// ```swift + /// let items = ["apple", "banana", "cherry"] + /// + /// let list = String.build { + /// items.map { item in + /// "- \(item)" + /// } + /// } + /// // - apple + /// // - banana + /// // - cherry + /// ``` + /// + /// - Parameter mapper: A `@StringBuilder` closure that transforms each element into a ``StringComponent``. + /// - Returns: A component that renders `mapper` applied to each element in order. + /// - Note: This overload is `@_disfavoredOverload`, so it participates in overload resolution only when the closure is constrained into returning a ``StringComponent``. + @StringBuilder + @_disfavoredOverload + public func map( + @StringBuilder mapper: (Element) -> some StringComponent + ) -> some StringComponent { + for value in self { + mapper(value) + } + } + +} diff --git a/Sources/Calligraphy/StringComposition/Entry.swift b/Sources/Calligraphy/StringComposition/Entry.swift index a8ec3ca..248f987 100644 --- a/Sources/Calligraphy/StringComposition/Entry.swift +++ b/Sources/Calligraphy/StringComposition/Entry.swift @@ -36,7 +36,18 @@ /// } /// ``` /// -/// The expanded property reads its default from the initializer expression you provide, and can be set on any component using ``StringComponent/environment(_:_:)-(_,Value)`` or read using ``StringEnvironment``. +/// Non-optional properties must provide an initial value. Optional properties may omit the initial value, in which case the default is `nil`: +/// +/// ```swift +/// extension StringEnvironmentValues { +/// +/// @StringEntry +/// public var prefix: String? // Default is `nil`, since `String?` is an optional and no default was provided. +/// +/// } +/// ``` +/// +/// The expanded property can be set on any component using ``StringComponent/environment(_:_:)-(_,Value)`` or read using ``StringEnvironment``. @available(macOS 14.0, macCatalyst 17.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) @attached(accessor) @attached(peer, names: prefixed(__Key_)) diff --git a/Sources/Calligraphy/StringComposition/Joined.swift b/Sources/Calligraphy/StringComposition/Joined.swift index c0ef4fa..af531d8 100644 --- a/Sources/Calligraphy/StringComposition/Joined.swift +++ b/Sources/Calligraphy/StringComposition/Joined.swift @@ -52,7 +52,7 @@ extension StringEnvironmentValues { /// /// Defaults to `"\n"`. Components such as ``Lines`` read this value to decide how to join their children. Set it on an ancestor component using ``StringComponent/joined(separator:)``. @StringEntry - public var separator: String = "\n" + public internal(set) var separator: String = "\n" } diff --git a/Sources/Calligraphy/StringComposition/LineSpacing.swift b/Sources/Calligraphy/StringComposition/LineSpacing.swift index 01f4647..760728f 100644 --- a/Sources/Calligraphy/StringComposition/LineSpacing.swift +++ b/Sources/Calligraphy/StringComposition/LineSpacing.swift @@ -48,7 +48,7 @@ extension StringEnvironmentValues { /// /// Defaults to `1`. ``Lines`` and components built on top of it read this value to decide how to join their children. Set it on an ancestor component using ``StringComponent/lineSpacing(_:)``. @StringEntry - public var lineSpacing: Int = 1 + public internal(set) var lineSpacing: Int = 1 } diff --git a/Sources/Calligraphy/StringComposition/PrefixLines.swift b/Sources/Calligraphy/StringComposition/PrefixLines.swift index 8823258..e14b5dd 100644 --- a/Sources/Calligraphy/StringComposition/PrefixLines.swift +++ b/Sources/Calligraphy/StringComposition/PrefixLines.swift @@ -50,7 +50,9 @@ extension StringComponent { public func prefixLines( with prefix: some StringProtocol ) -> some StringComponent { - prefixLines { prefix } + prefixLines { + prefix + } } } diff --git a/Sources/Calligraphy/StringComposition/QuotationMark.swift b/Sources/Calligraphy/StringComposition/QuotationMark.swift index a9af492..0ad0eec 100644 --- a/Sources/Calligraphy/StringComposition/QuotationMark.swift +++ b/Sources/Calligraphy/StringComposition/QuotationMark.swift @@ -25,23 +25,32 @@ /// A string component that renders a quotation mark. /// -/// The character (or characters) rendered are controlled by the surrounding ``QuotationMarkStyle`` environment value. By default, a `QuotationMark` renders as a single double-quote character (`"`). To use a different style, apply the ``StringComponent/quotationMarkStyle(_:)`` modifier to an ancestor component. +/// The character (or characters) rendered are controlled by the surrounding ``QuotationMarkStyle`` environment value. By default, a `QuotationMark` renders as a single double-quote character (`"`). To use a different style, apply the ``StringComponent/quotationMarkStyle(_:)`` modifier to an ancestor component, or pass an explicit style to the initializer to bypass the environment. @available(macOS 14.0, macCatalyst 17.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) public struct QuotationMark: StringComponent { // MARK: - Initializers /// Create a quotation mark component. - public init() {} + /// - Parameter style: An optional ``QuotationMarkStyle`` override. When `nil` (the default), the style is read from the surrounding ``StringEnvironmentValues/quotationMarkStyle`` environment value. + public init(_ style: QuotationMarkStyle? = nil) { + self.style = style + } // MARK: - StringComponent public var body: some StringComponent { - quotationMarkStyle + if let style { + style + } else { + quotationMarkStyle + } } // MARK: - Private + private let style: QuotationMarkStyle? + @StringEnvironment(\.quotationMarkStyle) private var quotationMarkStyle diff --git a/Sources/Calligraphy/StringComposition/QuotationMarkStyle.swift b/Sources/Calligraphy/StringComposition/QuotationMarkStyle.swift index 18181df..451945f 100644 --- a/Sources/Calligraphy/StringComposition/QuotationMarkStyle.swift +++ b/Sources/Calligraphy/StringComposition/QuotationMarkStyle.swift @@ -53,7 +53,7 @@ extension StringEnvironmentValues { /// /// Defaults to ``QuotationMarkStyle/default``. Set it on an ancestor component using ``StringComponent/quotationMarkStyle(_:)``. @StringEntry - public var quotationMarkStyle: QuotationMarkStyle = .default + public internal(set) var quotationMarkStyle: QuotationMarkStyle = .default } diff --git a/Sources/Calligraphy/StringComposition/Quote.swift b/Sources/Calligraphy/StringComposition/Quote.swift index 0ee1de6..c6085aa 100644 --- a/Sources/Calligraphy/StringComposition/Quote.swift +++ b/Sources/Calligraphy/StringComposition/Quote.swift @@ -34,34 +34,83 @@ extension StringComponent { public func quoted( _ style: QuotationMarkStyle? = nil ) -> some StringComponent { - if let style { - Quote { self } - .quotationMarkStyle(style) - } else { - Quote { self } - } + Quote(style) { self } } } -/// A string component that wraps content between two ``QuotationMark`` characters. +extension StringEnvironmentValues { + + /// A flag indicating whether the current component is being rendered inside a ``Quote``. + /// + /// ``Quote`` sets this value to `true` on its wrapped content before rendering, allowing a descendant component to branch on whether it is being quoted — for example, to escape a nested quotation mark or to omit one entirely. + /// + /// ```swift + /// struct Greeting: StringComponent { + /// + /// @StringEnvironment(\.isInQuote) + /// private var isInQuote: Bool + /// + /// var body: some StringComponent { + /// if isInQuote { + /// "hello" + /// } else { + /// "Hello!" + /// } + /// } + /// + /// } + /// ``` + /// + /// The value is read-only from outside this module — its lifecycle is managed by ``Quote``. Defaults to `false`. + @StringEntry + public internal(set) var isInQuote: Bool = false + +} + +/// A string component that wraps content between two `QuotationMark` components. /// -/// The style of the surrounding quotation marks is read from the current ``QuotationMarkStyle`` environment value, which can be overridden using ``StringComponent/quotationMarkStyle(_:)``. +/// The style of the surrounding quotation marks is read from the current ``QuotationMarkStyle`` environment value, which can be overridden using ``StringComponent/quotationMarkStyle(_:)`` or by passing an explicit style to the initializer. @available(macOS 14.0, macCatalyst 17.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) public struct Quote: StringComponent where Quote: StringComponent { /// Create a quoted component. - /// - Parameter quote: The content to wrap in quotation marks. + /// - Parameters: + /// - style: An optional ``QuotationMarkStyle`` override. When `nil` (the default), the style is read from the surrounding environment. When supplied, the style is also propagated into `quote` so nested ``QuotationMark`` and ``Quote`` components inherit it. + /// - quote: The content to wrap in quotation marks. public init( + _ style: QuotationMarkStyle? = nil, @StringBuilder quote: () -> Quote ) { + self.style = style self.quote = quote() } + // MARK: - StringComponent + public var body: some StringComponent { - QuotationMark() + quote + QuotationMark() + if let style { + content + .quotationMarkStyle(style) + } else { + content + } } + // MARK: - Private + + private let style: QuotationMarkStyle? private let quote: Quote + private var content: some StringComponent { + Line { + QuotationMark() + quote.environment( + \.isInQuote, + true + ) + QuotationMark() + } + } + } diff --git a/Sources/Calligraphy/StringComposition/StringBuilder.swift b/Sources/Calligraphy/StringComposition/StringBuilder.swift index 126caf8..37accfa 100644 --- a/Sources/Calligraphy/StringComposition/StringBuilder.swift +++ b/Sources/Calligraphy/StringComposition/StringBuilder.swift @@ -48,9 +48,9 @@ public enum StringBuilder { } @StringBuilder - public static func buildExpression( - _ expression: some Collection - ) -> some StringComponent { + public static func buildExpression( + _ expression: some Collection + ) -> _List where T: StringComponent { for element in expression { element } @@ -59,7 +59,7 @@ public enum StringBuilder { @StringBuilder public static func buildExpression( _ expression: some Collection - ) -> some StringComponent { + ) -> _List { for element in expression { element } @@ -68,7 +68,7 @@ public enum StringBuilder { @StringBuilder public static func buildExpression( _ expression: some Collection> - ) -> some StringComponent { + ) -> _List { for element in expression { element } diff --git a/Sources/Calligraphy/StringComposition/TabDefinition.swift b/Sources/Calligraphy/StringComposition/TabDefinition.swift index 8e56287..8cecf07 100644 --- a/Sources/Calligraphy/StringComposition/TabDefinition.swift +++ b/Sources/Calligraphy/StringComposition/TabDefinition.swift @@ -65,7 +65,7 @@ extension StringEnvironmentValues { /// /// Defaults to ``TabDefinition/default`` (two spaces). ``Tab`` and components built on top of it (such as ``Tabbed``) read this value when rendering. Set it on an ancestor component using ``StringComponent/tabDefinition(_:)``. @StringEntry - public var tabDefinition: TabDefinition = .default + public internal(set) var tabDefinition: TabDefinition = .default } diff --git a/Sources/CalligraphyCompilerPlugin/StringEntryMacro.swift b/Sources/CalligraphyCompilerPlugin/StringEntryMacro.swift index aaf03c0..917c542 100644 --- a/Sources/CalligraphyCompilerPlugin/StringEntryMacro.swift +++ b/Sources/CalligraphyCompilerPlugin/StringEntryMacro.swift @@ -57,14 +57,19 @@ public struct StringEntryMacro: AccessorMacro, PeerMacro { let typeAnnotation = try binding.typeAnnotation .mustExist("@StringEntry requires an explicit type annotation") .type - let initializer = try binding.initializer - .mustExist("@StringEntry requires an initial value") - .value + let defaultValue: ExprSyntax + if let initializer = binding.initializer?.value { + defaultValue = initializer + } else if isOptional(typeAnnotation) { + defaultValue = "nil" + } else { + throw MacroError("@StringEntry requires an initial value for non-optional types") + } let keyName = "__Key_\(name)" return [ """ private struct \(raw: keyName): StringEnvironmentKey { - static let defaultValue: \(typeAnnotation.trimmed) = \(initializer.trimmed) + static let defaultValue: \(typeAnnotation.trimmed) = \(defaultValue.trimmed) } """ ] @@ -87,4 +92,17 @@ public struct StringEntryMacro: AccessorMacro, PeerMacro { .identifier } + private static func isOptional(_ type: TypeSyntax) -> Bool { + if type.is(OptionalTypeSyntax.self) { + return true + } + if type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + return true + } + if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" { + return true + } + return false + } + } diff --git a/Tests/CalligraphyCompilerPluginTests/StringEntryMacroTests.swift b/Tests/CalligraphyCompilerPluginTests/StringEntryMacroTests.swift index 12848cb..ae1cd82 100644 --- a/Tests/CalligraphyCompilerPluginTests/StringEntryMacroTests.swift +++ b/Tests/CalligraphyCompilerPluginTests/StringEntryMacroTests.swift @@ -182,6 +182,146 @@ struct StringEntryMacroTests { #endif } + @Test("Optional type without initial value defaults to nil") + func optionalTypeDefaultsToNil() { + #if canImport(CalligraphyCompilerPlugin) + assertMacroExpansion( + """ + extension StringEnvironmentValues { + @StringEntry + var prefix: String? + } + """, + expandedSource: """ + extension StringEnvironmentValues { + var prefix: String? { + get { + self[__Key_prefix.self] + } + set { + self[__Key_prefix.self] = newValue + } + } + + private struct __Key_prefix: StringEnvironmentKey { + static let defaultValue: String? = nil + } + } + """, + macroSpecs: macroSpecs + ) { failure in + Issue.record("\(failure.message)") + } + #else + Issue.record("macros are only supported when running tests for the host platform") + #endif + } + + @Test("Optional long form without initial value defaults to nil") + func optionalLongFormDefaultsToNil() { + #if canImport(CalligraphyCompilerPlugin) + assertMacroExpansion( + """ + extension StringEnvironmentValues { + @StringEntry + var prefix: Optional + } + """, + expandedSource: """ + extension StringEnvironmentValues { + var prefix: Optional { + get { + self[__Key_prefix.self] + } + set { + self[__Key_prefix.self] = newValue + } + } + + private struct __Key_prefix: StringEnvironmentKey { + static let defaultValue: Optional = nil + } + } + """, + macroSpecs: macroSpecs + ) { failure in + Issue.record("\(failure.message)") + } + #else + Issue.record("macros are only supported when running tests for the host platform") + #endif + } + + @Test("Implicitly unwrapped optional without initial value defaults to nil") + func implicitlyUnwrappedOptionalDefaultsToNil() { + #if canImport(CalligraphyCompilerPlugin) + assertMacroExpansion( + """ + extension StringEnvironmentValues { + @StringEntry + var prefix: String! + } + """, + expandedSource: """ + extension StringEnvironmentValues { + var prefix: String! { + get { + self[__Key_prefix.self] + } + set { + self[__Key_prefix.self] = newValue + } + } + + private struct __Key_prefix: StringEnvironmentKey { + static let defaultValue: String! = nil + } + } + """, + macroSpecs: macroSpecs + ) { failure in + Issue.record("\(failure.message)") + } + #else + Issue.record("macros are only supported when running tests for the host platform") + #endif + } + + @Test("Optional type with explicit initial value is preserved") + func optionalTypeWithExplicitValue() { + #if canImport(CalligraphyCompilerPlugin) + assertMacroExpansion( + #""" + extension StringEnvironmentValues { + @StringEntry + var prefix: String? = "default" + } + """#, + expandedSource: #""" + extension StringEnvironmentValues { + var prefix: String? { + get { + self[__Key_prefix.self] + } + set { + self[__Key_prefix.self] = newValue + } + } + + private struct __Key_prefix: StringEnvironmentKey { + static let defaultValue: String? = "default" + } + } + """#, + macroSpecs: macroSpecs + ) { failure in + Issue.record("\(failure.message)") + } + #else + Issue.record("macros are only supported when running tests for the host platform") + #endif + } + @Test("Missing type annotation emits diagnostic") func requiresTypeAnnotation() { #if canImport(CalligraphyCompilerPlugin) @@ -239,7 +379,7 @@ struct StringEntryMacroTests { } """, diagnostics: [ - DiagnosticSpec(message: "@StringEntry requires an initial value", line: 2, column: 5) + DiagnosticSpec(message: "@StringEntry requires an initial value for non-optional types", line: 2, column: 5) ], macroSpecs: macroSpecs ) { failure in diff --git a/Tests/CalligraphyTests/StringComposition/EntryTests.swift b/Tests/CalligraphyTests/StringComposition/EntryTests.swift index eededc7..ab46d96 100644 --- a/Tests/CalligraphyTests/StringComposition/EntryTests.swift +++ b/Tests/CalligraphyTests/StringComposition/EntryTests.swift @@ -31,6 +31,9 @@ extension StringEnvironmentValues { @StringEntry var entryTestGreeting: String = "Hello" + @StringEntry + var entryTestPrefix: String? + } @Suite("@StringEntry Tests", .tags(.stringComposition)) @@ -47,6 +50,17 @@ struct EntryTests { } + private struct OptionalReader: StringComponent { + + @StringEnvironment(\.entryTestPrefix) + var prefix: String? + + var body: some StringComponent { + prefix ?? "" + } + + } + @Test("Generated Default Value") func defaultValue() { #expect(String(Reader()) == "Hello") @@ -59,4 +73,16 @@ struct EntryTests { #expect(String(component) == "Howdy") } + @Test("Optional Entry Defaults to nil") + func optionalDefault() { + #expect(String(OptionalReader()) == "") + } + + @Test("Optional Entry Accepts Value") + func optionalOverride() { + let component = OptionalReader() + .environment(\.entryTestPrefix, "$") + #expect(String(component) == "$") + } + } diff --git a/Tests/CalligraphyTests/StringComposition/EnvironmentModifierTests.swift b/Tests/CalligraphyTests/StringComposition/EnvironmentModifierTests.swift index 7bfd440..e4660fe 100644 --- a/Tests/CalligraphyTests/StringComposition/EnvironmentModifierTests.swift +++ b/Tests/CalligraphyTests/StringComposition/EnvironmentModifierTests.swift @@ -23,7 +23,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Calligraphy +@testable import Calligraphy import Testing @Suite("Environment Modifier Tests", .tags(.stringComposition)) @@ -79,11 +79,11 @@ struct EnvironmentModifierTests { @Test("Closest Modifier Wins") func closestWins() { let component = ReadEnvironment { environment in - environment.separator + "\(environment.lineSpacing)" } - .environment(\.separator, "inner") - .environment(\.separator, "outer") - #expect(String(component) == "inner") + .environment(\.lineSpacing, 1) + .environment(\.lineSpacing, 9) + #expect(String(component) == "1") } } diff --git a/Tests/CalligraphyTests/StringComposition/QuotationMarkTests.swift b/Tests/CalligraphyTests/StringComposition/QuotationMarkTests.swift index 8d4cbd8..53773b0 100644 --- a/Tests/CalligraphyTests/StringComposition/QuotationMarkTests.swift +++ b/Tests/CalligraphyTests/StringComposition/QuotationMarkTests.swift @@ -63,4 +63,17 @@ struct QuotationMarkTests { #expect(String(mark) == "\"\"\"") } + @Test("Explicit Style Initializer") + func explicitStyle() { + #expect(String(QuotationMark(.single)) == "'") + #expect(String(QuotationMark(.tripleDouble)) == "\"\"\"") + } + + @Test("Explicit Style Overrides Ancestor Environment") + func explicitStyleOverridesEnvironment() { + let mark = QuotationMark(.single) + .quotationMarkStyle(.double) + #expect(String(mark) == "'") + } + } diff --git a/Tests/CalligraphyTests/StringComposition/QuoteTests.swift b/Tests/CalligraphyTests/StringComposition/QuoteTests.swift index 91fcef2..5553697 100644 --- a/Tests/CalligraphyTests/StringComposition/QuoteTests.swift +++ b/Tests/CalligraphyTests/StringComposition/QuoteTests.swift @@ -66,4 +66,38 @@ struct QuoteTests { #expect(String(quoted) == expected) } + @Test("Explicit Style Initializer") + func explicitStyle() { + let quote = Quote(.single) { + "foo" + } + #expect(String(quote) == "'foo'") + } + + @Test("Explicit Style Modifier Argument") + func explicitStyleModifier() { + let quoted = RawStringComponent("foo") + .quoted(.tripleDouble) + #expect(String(quoted) == "\"\"\"foo\"\"\"") + } + + @Test("Explicit Style Propagates Into Nested Quote") + func explicitStylePropagates() { + let quote = Quote(.single) { + Quote { + "foo" + } + } + #expect(String(quote) == "''foo''") + } + + @Test("Explicit Style Overrides Ancestor Environment") + func explicitStyleOverridesEnvironment() { + let quote = Quote(.single) { + "foo" + } + .quotationMarkStyle(.double) + #expect(String(quote) == "'foo'") + } + } diff --git a/Tests/CalligraphyTests/StringComposition/ReadEnvironmentTests.swift b/Tests/CalligraphyTests/StringComposition/ReadEnvironmentTests.swift index bdcfe7a..d29132b 100644 --- a/Tests/CalligraphyTests/StringComposition/ReadEnvironmentTests.swift +++ b/Tests/CalligraphyTests/StringComposition/ReadEnvironmentTests.swift @@ -23,7 +23,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Calligraphy +@testable import Calligraphy import Testing @Suite("Read Environment Tests", .tags(.stringComposition)) @@ -40,23 +40,23 @@ struct ReadEnvironmentTests { @Test("Reads Injected Environment") func readInjected() { let component = ReadEnvironment { environment in - environment.separator + "\(environment.lineSpacing)" } - .environment(\.separator, ", ") - #expect(String(component) == ", ") + .environment(\.lineSpacing, 5) + #expect(String(component) == "5") } @Test("Branches on Environment Value") func conditionalBody() { - let multiline = ReadEnvironment { environment in - if environment.separator == "\n" { - "multiline" + let component = ReadEnvironment { environment in + if environment.lineSpacing == 1 { + "default" } else { - "inline" + "spaced" } } - #expect(String(multiline) == "multiline") - #expect(String(multiline.environment(\.separator, ", ")) == "inline") + #expect(String(component) == "default") + #expect(String(component.environment(\.lineSpacing, 3)) == "spaced") } } diff --git a/Tests/CalligraphyTests/StringComposition/StringComponentTests.swift b/Tests/CalligraphyTests/StringComposition/StringComponentTests.swift index da37647..efa0e8e 100644 --- a/Tests/CalligraphyTests/StringComposition/StringComponentTests.swift +++ b/Tests/CalligraphyTests/StringComposition/StringComponentTests.swift @@ -82,18 +82,18 @@ struct StringComponentTests { struct Reader: StringComponent { - @StringEnvironment(\.separator) - var separator: String + @StringEnvironment(\.lineSpacing) + var spacing: Int var body: some StringComponent { - separator + "\(spacing)" } } let component = Reader() - .environment(\.separator, "|") - #expect(String(component) == "|") + .environment(\.lineSpacing, 5) + #expect(String(component) == "5") } } diff --git a/Tests/CalligraphyTests/StringComposition/StringEnvironmentTests.swift b/Tests/CalligraphyTests/StringComposition/StringEnvironmentTests.swift index e4986a3..0650a8a 100644 --- a/Tests/CalligraphyTests/StringComposition/StringEnvironmentTests.swift +++ b/Tests/CalligraphyTests/StringComposition/StringEnvironmentTests.swift @@ -23,7 +23,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import Calligraphy +@testable import Calligraphy import Testing @Suite("@StringEnvironment Tests", .tags(.stringComposition)) diff --git a/Tests/CalligraphyTests/StringExtensionsTests.swift b/Tests/CalligraphyTests/StringExtensionsTests.swift index 3f75156..a6f16dc 100644 --- a/Tests/CalligraphyTests/StringExtensionsTests.swift +++ b/Tests/CalligraphyTests/StringExtensionsTests.swift @@ -26,29 +26,29 @@ import Calligraphy import Testing -// @Suite("String Extensions Tests", .tags(.stringComposition)) -// struct StringExtensionsTests { -// -// @Test("Initializer") -// func initializer() { -// let str = String(RawStringComponent("Foo")) -// #expect(str == "Foo") -// } -// -// @Test("Initializer with Empty Component") -// func initializerEmpty() { -// let component = StringComponents {} -// let str = String(component) -// #expect(str == "") -// } -// -// @Test("Build type method") -// func build() { -// let str = String.build { -// RawStringComponent("Foo") -// RawStringComponent("Bar") -// } -// #expect(str == "Foo\nBar") -// } -// -// } +@Suite("String Extensions Tests", .tags(.stringComposition)) +struct StringExtensionsTests { + + @Test("Initializer") + func initializer() { + let str = String(RawStringComponent("Foo")) + #expect(str == "Foo") + } + + @Test("Initializer with Empty Component") + func initializerEmpty() { + let component = StringComponents {} + let str = String(component) + #expect(str == "") + } + + @Test("Build type method") + func build() { + let str = String.build { + RawStringComponent("Foo") + RawStringComponent("Bar") + } + #expect(str == "Foo\nBar") + } + +}