From f86ad41f917b5306861a97ff9f836aa6fa01b866 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Mon, 22 Jun 2026 20:44:35 +0500 Subject: [PATCH 1/2] Detect single-line previews like #Preview("x") { foo() } Single-line previews whose closure opens and closes on the same line as the #Preview macro were silently skipped, so no snapshot test was generated for them. Two issues, both fixed here: - PreviewLoader.previewBodies started tracking the brace balance only when a line had a non-zero brace change. On a single-line preview the opening and closing braces cancel out (braceChange == 0), so the balance was never initialised and the body was never collected. It now starts tracking as soon as a line contains the opening brace. - RawPreviewModel.init derived the first line via `dropLast(2)` + `removeFirst()`, which traps on a single-line body (the collection is empty after dropLast(2)). It now takes the first line directly; the body and properties are already parsed from the full macro body via SwiftSyntax, so nothing else is affected. Adds unit tests for both code paths. --- .../PrefireCore/Previews/PreviewLoader.swift | 7 ++++- .../Previews/RawPreviewModel.swift | 10 +++++-- .../PrefireTests/PreviewLoaderTests.swift | 30 +++++++++++++++++++ .../PrefireTests/RawPreviewModelTests.swift | 10 +++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift index 6c1a241..6d17c6a 100644 --- a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift +++ b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift @@ -43,7 +43,12 @@ enum PreviewLoader { return count } - if braceChange != 0 || braceBalance != nil { + // Start tracking the brace balance as soon as the closure's opening brace appears, + // even when it is balanced on the same line. Relying on `braceChange != 0` here + // skipped single-line previews like `#Preview("x") { previewFoo() }`, where the + // opening and closing braces cancel out (`braceChange == 0`) so the balance was + // never initialised and the body was never collected. + if braceBalance != nil || line.contains(Constants.openingBrace) { braceBalance = (braceBalance ?? 0) + braceChange } diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift b/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift index f143c97..6bde060 100644 --- a/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift +++ b/PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift @@ -29,8 +29,14 @@ extension RawPreviewModel { init?(from macroBody: String, filename: String) { guard !macroBody.isEmpty else { return nil } - var lines = macroBody.split(separator: "\n", omittingEmptySubsequences: false).dropLast(2) - let firstLine = lines.removeFirst() + // Only the first line is needed here (for displayName and traits); the body and + // properties are parsed from the full `macroBody` via SwiftSyntax below. Taking the + // first line directly avoids crashing on single-line previews like + // `#Preview("x") { previewFoo() }`, where `dropLast(2)` leaves an empty collection + // and `removeFirst()` would trap. + guard let firstLine = macroBody.split(separator: "\n", omittingEmptySubsequences: false).first else { + return nil + } // Define displayName by splitting the first line by " let parts = firstLine.split(separator: "\"") diff --git a/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift b/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift index 6416fe3..258c095 100644 --- a/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift @@ -49,6 +49,36 @@ class PreviewLoaderTests: XCTestCase { XCTAssertEqual(previews?.count, 1) XCTAssertEqual(previews?[0], previewRepresentations[3]) } + + func test_loadSingleLinePreviewBody() { + let content = "#Preview(\"SingleLine\") { previewFoo() }\n" + let previews = PreviewLoader.previewBodies(from: content, defaultEnabled: true) + + XCTAssertEqual(previews?.count, 1) + XCTAssertEqual(previews?[0], content) + } + + func test_loadSingleLineAndMultiLinePreviewBodies() { + let content = """ + #Preview("SingleLine") { previewFoo() } + #Preview("MultiLine") { + Text("TestView") + } + + """ + let previews = PreviewLoader.previewBodies(from: content, defaultEnabled: true) + + XCTAssertEqual(previews?.count, 2) + XCTAssertEqual(previews?[0], "#Preview(\"SingleLine\") { previewFoo() }\n") + XCTAssertEqual(previews?[1], "#Preview(\"MultiLine\") {\n Text(\"TestView\")\n}\n") + } + + func test_loadSingleLinePreviewBodyDefaultDisabled() { + let content = "#Preview(\"SingleLine\") { previewFoo() }\n" + let previews = PreviewLoader.previewBodies(from: content, defaultEnabled: false) + + XCTAssertNil(previews) + } } // MARK: - Previews diff --git a/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift b/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift index 61e4c19..c5e077b 100644 --- a/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/RawPreviewModelTests.swift @@ -201,4 +201,14 @@ class RawPreviewModelTests: XCTestCase { XCTAssertEqual(rawPreviewModel?.displayName, "Test") XCTAssertEqual(rawPreviewModel?.traits, [".device"]) } + + func test_initWithSingleLineBody() { + let previewBody = "#Preview(\"SingleLine\", traits: .sizeThatFitsLayout) { Text(\"TestView\") }\n" + let rawPreviewModel = RawPreviewModel(from: previewBody, filename: "Test") + + XCTAssertEqual(rawPreviewModel?.body, "Text(\"TestView\")") + XCTAssertEqual(rawPreviewModel?.properties, nil) + XCTAssertEqual(rawPreviewModel?.displayName, "SingleLine") + XCTAssertEqual(rawPreviewModel?.traits, [".sizeThatFitsLayout"]) + } } From 2b115d7e8987c3be5b9e13a69f98a8e9ab137179 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Wed, 8 Jul 2026 21:02:21 +0500 Subject: [PATCH 2/2] Extract #Preview macros via SwiftSyntax instead of brace scanning The line-based brace scanner miscounted braces inside string literals, comments and raw strings, truncating previews like `#Preview("{braced}")`. Locate macros with SwiftSyntax so the real source range is always correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PrefireCore/Previews/PreviewLoader.swift | 105 ++++++++++-------- .../PrefireTests/PreviewLoaderTests.swift | 14 +++ 2 files changed, 71 insertions(+), 48 deletions(-) diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift index 6d17c6a..5f1624b 100644 --- a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift +++ b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift @@ -1,13 +1,13 @@ import Foundation +import SwiftParser +import SwiftSyntax /// Simplify and improve the process of locating and parsing generated preview code for Swift projects. enum PreviewLoader { enum Constants { - static let previewMarker = "#Preview" + static let previewMacro = "Preview" static let prefireDisableMarker = ".prefireIgnored()" static let prefireEnabledMarker = ".prefireEnabled()" - static let openingBrace: Character = "{" - static let closingBrace: Character = "}" } /// Extract the preview body using the passed content @@ -15,63 +15,72 @@ enum PreviewLoader { /// - Parameters: /// - content: File content /// - defaultEnabled: Whether automatic view inclusion should be allowed. Default value is true. - /// - Returns: An array representing the results of the macro preview without the initial `#Preview` and final `}`. + /// - Returns: An array representing the results of the macro preview, each starting with `#Preview` + /// and ending with the closing `}` of the preview closure followed by a newline. static func previewBodies(from content: String, defaultEnabled: Bool) -> [String]? { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false) + // Locate `#Preview` macros with SwiftSyntax rather than a line-based brace scanner. + // The scanner used to miscount braces that appear inside string literals, comments or + // raw strings (e.g. `#Preview("{braced}")`), truncating the collected body. SwiftSyntax + // understands the grammar, so the macro's real source range is always correct. + let sourceFile = Parser.parse(source: content) + let collector = PreviewMacroCollector() + collector.walk(sourceFile) + + let sourceBytes = Array(content.utf8) var previewBodies: [String] = [] - var currentBody: String = "" - var previewWasFound = false - var viewMustBeLoaded = defaultEnabled - var braceBalance: Int? = nil + for preview in collector.previews { + guard preview.startOffset <= preview.endOffset, preview.endOffset <= sourceBytes.count else { continue } + let body = String(decoding: sourceBytes[preview.startOffset.. SyntaxVisitorContinueKind { + record(macroName: node.macroName.text, pound: node.pound, node: node) + return .visitChildren + } - return previewBodies.isEmpty ? nil : previewBodies + override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + record(macroName: node.macroName.text, pound: node.pound, node: node) + return .visitChildren + } + + /// Records the macro's range, starting at the `#` token so that leading attributes + /// (e.g. `@available(...)`) are excluded, matching the shape the downstream parser expects. + private func record(macroName: String, pound: TokenSyntax, node: some SyntaxProtocol) { + guard macroName == PreviewLoader.Constants.previewMacro else { return } + previews.append( + PreviewRange( + startOffset: pound.positionAfterSkippingLeadingTrivia.utf8Offset, + endOffset: node.endPositionBeforeTrailingTrivia.utf8Offset + ) + ) } } diff --git a/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift b/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift index 258c095..74c351a 100644 --- a/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift @@ -79,6 +79,20 @@ class PreviewLoaderTests: XCTestCase { XCTAssertNil(previews) } + + func test_loadPreviewWithBraceInDisplayName() { + let content = """ + #Preview("{braced}") + { + Text("TestView") + } + + """ + let previews = PreviewLoader.previewBodies(from: content, defaultEnabled: true) + + XCTAssertEqual(previews?.count, 1) + XCTAssertEqual(previews?[0], "#Preview(\"{braced}\")\n{\n Text(\"TestView\")\n}\n") + } } // MARK: - Previews