diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d27578..62300e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/SwiflowMacrosPlugin/PersistedMacro.swift b/Sources/SwiflowMacrosPlugin/PersistedMacro.swift index a753b064..de60e611 100644 --- a/Sources/SwiflowMacrosPlugin/PersistedMacro.swift +++ b/Sources/SwiflowMacrosPlugin/PersistedMacro.swift @@ -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 [] } @@ -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 [] } diff --git a/Sources/SwiflowMacrosPlugin/StateMacro.swift b/Sources/SwiflowMacrosPlugin/StateMacro.swift index 86ec7f65..464cd841 100644 --- a/Sources/SwiflowMacrosPlugin/StateMacro.swift +++ b/Sources/SwiflowMacrosPlugin/StateMacro.swift @@ -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 [] } diff --git a/Sources/SwiflowQuery/QueryMacros.swift b/Sources/SwiflowQuery/QueryMacros.swift index ed8a0e81..b6745beb 100644 --- a/Sources/SwiflowQuery/QueryMacros.swift +++ b/Sources/SwiflowQuery/QueryMacros.swift @@ -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") diff --git a/Tests/SwiflowMacrosTests/PeerGuardDirectTests.swift b/Tests/SwiflowMacrosTests/PeerGuardDirectTests.swift new file mode 100644 index 00000000..2deb410b --- /dev/null +++ b/Tests/SwiflowMacrosTests/PeerGuardDirectTests.swift @@ -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)") + } +} diff --git a/Tests/SwiflowMacrosTests/PersistedMacroTests.swift b/Tests/SwiflowMacrosTests/PersistedMacroTests.swift index 43219e87..cb6df778 100644 --- a/Tests/SwiflowMacrosTests/PersistedMacroTests.swift +++ b/Tests/SwiflowMacrosTests/PersistedMacroTests.swift @@ -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" + } + """ ) } diff --git a/Tests/SwiflowMacrosTests/QueryMacroTests.swift b/Tests/SwiflowMacrosTests/QueryMacroTests.swift index e75404f7..8029f3bc 100644 --- a/Tests/SwiflowMacrosTests/QueryMacroTests.swift +++ b/Tests/SwiflowMacrosTests/QueryMacroTests.swift @@ -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 + ) + } +}