diff --git a/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift b/PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift index 6c1a241..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,58 +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/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..74c351a 100644 --- a/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift +++ b/PrefireExecutable/Tests/PrefireTests/PreviewLoaderTests.swift @@ -49,6 +49,50 @@ 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) + } + + 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 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"]) + } }