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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com).
render via the plain `Double` path on every platform). `DataTable`'s scroll
handler and virtual-runway padding route through the same trap-free pixel
clamp as the rest of its pixel math.
- `@State`/`@Persisted` on a tuple or wildcard binding (`@State var (a, b) =
(0, 1)`) is now a compile error. Previously it was silently accepted and
compiled as plain, non-reactive storage — no state cell, no persistence,
no HMR snapshot.
- `@Persisted` on a `let` now offers the same "Replace 'let' with 'var'"
FixIt as its sibling state macros.

### Removed

Expand Down
18 changes: 15 additions & 3 deletions Sources/SwiflowMacrosPlugin/PersistedMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public struct PersistedMacro: AccessorMacro, PeerMacro {
guard varDecl.bindingSpecifier.tokenKind == .keyword(.var) else {
context.diagnose(Diagnostic(
node: Syntax(varDecl),
message: PersistedMacroDiagnostic.requiresVar
message: PersistedMacroDiagnostic.requiresVar,
fixIts: [MacroFixIt.letToVar(varDecl.bindingSpecifier)]
))
return []
}
Expand Down Expand Up @@ -96,8 +97,19 @@ public struct PersistedMacro: AccessorMacro, PeerMacro {
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
let binding = varDecl.bindings.first else {
return []
}

// Reject tuple/wildcard patterns FIRST: the compiler never runs the
// accessor role for them, so every downstream "accessor path
// diagnosed it" bail would be silent — and `@Persisted var (a, b)`
// would compile as plain storage that never persists.
guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
context.diagnose(Diagnostic(
node: Syntax(varDecl),
message: PersistedMacroDiagnostic.requiresSingleBinding
))
return []
}

Expand Down
16 changes: 14 additions & 2 deletions Sources/SwiflowMacrosPlugin/StateMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,20 @@ public struct StateMacro: AccessorMacro, PeerMacro {
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
let binding = varDecl.bindings.first else {
return []
}

// Reject tuple/wildcard patterns FIRST: the compiler never runs the
// accessor role for them, so every downstream "accessor path
// diagnosed it" bail would be silent — and `@State var (a, b)`
// would compile as plain, non-reactive storage (no cell, no HMR
// snapshot).
guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
context.diagnose(Diagnostic(
node: Syntax(varDecl),
message: StateMacroDiagnostic.requiresSingleBinding
))
return []
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiflowQuery/QueryMacros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public macro Key() = #externalMacro(module: "SwiflowMacrosPlugin", type: "KeyMac
/// }
/// ```
@attached(extension, conformances: Query)
@attached(member, names: named(queryKey), arbitrary)
@attached(member, names: named(queryKey), named(init))
@attached(memberAttribute)
public macro Query(prefix: String? = nil) =
#externalMacro(module: "SwiflowMacrosPlugin", type: "QueryMacro")
Expand Down
87 changes: 87 additions & 0 deletions Tests/SwiflowMacrosTests/PeerGuardDirectTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Tests/SwiflowMacrosTests/PeerGuardDirectTests.swift
//
// Direct-invocation coverage for @State/@Persisted's peer-path guards.
// These shapes CANNOT be pinned through assertMacroExpansion: the test
// harness injects its own "peer macro can only be applied to a single
// variable" error for multi-binding vars (which the real compiler never
// emits — it runs the peer and relies on these guards), and for tuple
// patterns the compiler skips the accessor role entirely, making the peer
// diagnostic the ONLY thing standing between the user and silently
// non-reactive plain storage. Calling the peer expansion directly on
// parsed syntax is the one vehicle that exercises the guards themselves.
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftSyntaxMacroExpansion
import XCTest
@testable import SwiflowMacrosPlugin

final class PeerGuardDirectTests: XCTestCase {

/// Runs `macro`'s peer expansion over the declaration parsed from
/// `source` and returns (emitted peer count, diagnostic messages).
private func runPeer(
_ macro: PeerMacro.Type,
attribute: String,
on source: DeclSyntax
) throws -> (peers: Int, messages: [String]) {
let attr = AttributeSyntax(attributeName: TypeSyntax(stringLiteral: attribute))
let context = BasicMacroExpansionContext()
let peers = try macro.expansion(of: attr, providingPeersOf: source, in: context)
return (peers.count, context.diagnostics.map { $0.diagMessage.message })
}

// MARK: - Tuple / wildcard patterns (the silent-guarantee-break shape)

func testStateTuplePatternIsDiagnosed() throws {
let decl: DeclSyntax = "var (width, height) = (0.0, 0.0)"
let result = try runPeer(StateMacro.self, attribute: "State", on: decl)
XCTAssertEqual(result.peers, 0)
XCTAssertEqual(result.messages.count, 1)
XCTAssertTrue(result.messages[0].contains("single property declaration"),
"got: \(result.messages)")
}

func testPersistedTuplePatternIsDiagnosed() throws {
let decl: DeclSyntax = #"var (theme, locale) = ("light", "en")"#
let result = try runPeer(PersistedMacro.self, attribute: "Persisted", on: decl)
XCTAssertEqual(result.peers, 0)
XCTAssertEqual(result.messages.count, 1)
XCTAssertTrue(result.messages[0].contains("single property declaration"),
"got: \(result.messages)")
}

func testStateWildcardPatternIsDiagnosed() throws {
let decl: DeclSyntax = "var _ = 0"
let result = try runPeer(StateMacro.self, attribute: "State", on: decl)
XCTAssertEqual(result.peers, 0)
XCTAssertEqual(result.messages.count, 1)
}

// MARK: - Multi-binding (harness diverges from the real compiler here)

func testStateMultiBindingIsDiagnosed() throws {
let decl: DeclSyntax = "var a: Int = 0, b: Int = 0"
let result = try runPeer(StateMacro.self, attribute: "State", on: decl)
XCTAssertEqual(result.peers, 0)
XCTAssertEqual(result.messages.count, 1)
XCTAssertTrue(result.messages[0].contains("single property declaration"),
"got: \(result.messages)")
}

func testPersistedMultiBindingIsDiagnosed() throws {
let decl: DeclSyntax = #"var a: String = "x", b: String = "y""#
let result = try runPeer(PersistedMacro.self, attribute: "Persisted", on: decl)
XCTAssertEqual(result.peers, 0)
XCTAssertEqual(result.messages.count, 1)
}

// MARK: - Control: a well-formed cell emits its peer with no diagnostics

func testStateWellFormedEmitsProjectionSilently() throws {
let decl: DeclSyntax = "var count: Int = 0"
let result = try runPeer(StateMacro.self, attribute: "State", on: decl)
XCTAssertEqual(result.peers, 1)
XCTAssertTrue(result.messages.isEmpty, "got: \(result.messages)")
}
}
11 changes: 9 additions & 2 deletions Tests/SwiflowMacrosTests/PersistedMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,17 @@ final class PersistedMacroTests: XCTestCase {
diagnostics: [
DiagnosticSpec(
message: "@Persisted requires a `var` — persisted cells must be mutable.",
line: 2, column: 5, severity: .error
line: 2, column: 5, severity: .error,
fixIts: [FixItSpec(message: "Replace 'let' with 'var'")]
),
],
macros: testMacros
macros: testMacros,
applyFixIts: ["Replace 'let' with 'var'"],
fixedSource: """
final class Prefs {
@Persisted var theme: String = "light"
}
"""
)
}

Expand Down
66 changes: 66 additions & 0 deletions Tests/SwiflowMacrosTests/QueryMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,69 @@ final class QueryMacroTests: XCTestCase {
)
}
}

// prefixMustBeLiteral: the key prefix is cache identity, so it must be
// statically known. A non-literal (or interpolated) prefix is diagnosed and
// the expansion falls back to the type-name prefix.
extension QueryMacroTests {

func testNonLiteralPrefixIsDiagnosed() {
assertMacroExpansion(
"""
@Query(prefix: dynamicPrefix) struct TodoList {
func fetch() async throws -> [Todo] { [] }
}
""",
expandedSource: """
struct TodoList {
@MainActor
func fetch() async throws -> [Todo] { [] }

var queryKey: QueryKey {
["TodoList"]
}

init() {
}
}
""",
diagnostics: [
DiagnosticSpec(
message: "@Query(prefix:) requires a string literal — the key prefix must be statically known for the cache.",
line: 1, column: 16, severity: .error
),
],
macros: testMacros
)
}

func testInterpolatedPrefixIsDiagnosed() {
assertMacroExpansion(
#"""
@Query(prefix: "todos-\(region)") struct TodoList {
func fetch() async throws -> [Todo] { [] }
}
"""#,
expandedSource: """
struct TodoList {
@MainActor
func fetch() async throws -> [Todo] { [] }

var queryKey: QueryKey {
["TodoList"]
}

init() {
}
}
""",
diagnostics: [
DiagnosticSpec(
message: "@Query(prefix:) requires a string literal — the key prefix must be statically known for the cache.",
line: 1, column: 16, severity: .error
),
],
macros: testMacros
)
}
}
Loading