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
10 changes: 5 additions & 5 deletions PrefireExecutable/Sources/PrefireCore/PrefireCacheManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ struct PrefireCacheManager {
sources: [Path],
template: String,
parseTypes: () throws -> Types,
parsePreviews: () async throws -> [String: String]
) async throws -> (types: Types, previews: [String: String]) {
let key = fingerprint(for: sources, extra: template)
parsePreviews: () async throws -> [String: RawPreviewModel]
) async throws -> (types: Types, previews: [String: RawPreviewModel]) {
let key = fingerprint(for: sources, extra: template + "\npreview-model-cache-v2")
let dir = Path.cachesDir(sourcePath: sources.first ?? .current, basePath: cacheBasePath)
let typesFile = dir + "\(version)-\(key).types"
let previewsFile = dir + "\(version)-\(key).previews.json"

var types: Types?
var previews: [String: String]?
var previews: [String: RawPreviewModel]?

if typesFile.exists {
do {
Expand All @@ -38,7 +38,7 @@ struct PrefireCacheManager {
if previewsFile.exists {
do {
let data = try Data(contentsOf: previewsFile.url)
previews = try JSONDecoder().decode([String: String].self, from: data)
previews = try JSONDecoder().decode([String: RawPreviewModel].self, from: data)
} catch {
Logger.warning("⚠️ Failed to read Previews cache: \(error)")
}
Expand Down
16 changes: 8 additions & 8 deletions PrefireExecutable/Sources/PrefireCore/PrefireGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,15 @@ public enum PrefireGenerator {
},
parsePreviews: {
Logger.info("🔍 Extracting #Preview bodies...")
var result: [String: String] = [:]
var result: [String: RawPreviewModel] = [:]
for (path, content) in fileContents {
guard content.contains("#Preview") else { continue }
if let bodies = PreviewLoader.previewBodies(from: content, defaultEnabled: defaultEnabled) {
for (i, body) in bodies.enumerated() {
let key = "\(path.lastComponentWithoutExtension)_\(i)"
result[key] = body
}
if let models = PreviewLoader.previewModels(
from: content,
filename: path.lastComponentWithoutExtension,
defaultEnabled: defaultEnabled
) {
result.merge(models) { current, _ in current }
}
}
return result
Expand All @@ -76,8 +77,7 @@ public enum PrefireGenerator {
let previewModels = previews
.sorted { $0.key > $1.key }
.compactMap { entry -> [String: Any?]? in
guard let model = RawPreviewModel(from: entry.value, filename: entry.key) else { return nil }
var dict = model.makeStencilDict()
var dict = entry.value.makeStencilDict()
// Add the source filename for ungrouped generation
dict["sourceFileName"] = extractFileNameFromKey(entry.key)
return dict
Expand Down
44 changes: 40 additions & 4 deletions PrefireExecutable/Sources/PrefireCore/Previews/PreviewLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftSyntax
enum PreviewLoader {
enum Constants {
static let previewMacro = "Preview"
static let defaultTrait = ".device"
static let prefireDisableMarker = ".prefireIgnored()"
static let prefireEnabledMarker = ".prefireEnabled()"
}
Expand All @@ -18,6 +19,37 @@ enum PreviewLoader {
/// - 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 previews = rawPreviews(from: content, defaultEnabled: defaultEnabled)
return previews?.map(\.body)
}

static func previewModels(from content: String, filename: String, defaultEnabled: Bool) -> [String: RawPreviewModel]? {
guard let previews = rawPreviews(from: content, defaultEnabled: defaultEnabled) else { return nil }

var models: [String: RawPreviewModel] = [:]
for (index, preview) in previews.enumerated() {
let key = "\(filename)_\(index)"
models[key] = RawPreviewModel(
displayName: preview.parser.displayName ?? key,
traits: preview.parser.traits ?? [Constants.defaultTrait],
body: preview.parser.body ?? "",
properties: preview.parser.propertiesSource,
arguments: preview.parser.arguments,
argumentPattern: preview.parser.argumentPattern
)
}

return models.isEmpty ? nil : models
}
}

private extension PreviewLoader {
struct RawPreview {
let body: String
let parser: PreviewParser
}

static func rawPreviews(from content: String, defaultEnabled: Bool) -> [RawPreview]? {
// 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
Expand All @@ -27,7 +59,7 @@ enum PreviewLoader {
collector.walk(sourceFile)

let sourceBytes = Array(content.utf8)
var previewBodies: [String] = []
var previews: [RawPreview] = []

for preview in collector.previews {
guard preview.startOffset <= preview.endOffset, preview.endOffset <= sourceBytes.count else { continue }
Expand All @@ -41,11 +73,11 @@ enum PreviewLoader {
}

if viewMustBeLoaded {
previewBodies.append(body)
previews.append(RawPreview(body: body, parser: preview.parser))
}
}

return previewBodies.isEmpty ? nil : previewBodies
return previews.isEmpty ? nil : previews
}
}

Expand All @@ -54,6 +86,7 @@ private final class PreviewMacroCollector: SyntaxVisitor {
struct PreviewRange {
let startOffset: Int
let endOffset: Int
let parser: PreviewParser
}

private(set) var previews: [PreviewRange] = []
Expand All @@ -76,10 +109,13 @@ private final class PreviewMacroCollector: SyntaxVisitor {
/// (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 }
let parser = PreviewParser()
parser.walk(node)
previews.append(
PreviewRange(
startOffset: pound.positionAfterSkippingLeadingTrivia.utf8Offset,
endOffset: node.endPositionBeforeTrailingTrivia.utf8Offset
endOffset: node.endPositionBeforeTrailingTrivia.utf8Offset,
parser: parser
)
)
}
Expand Down
92 changes: 92 additions & 0 deletions PrefireExecutable/Sources/PrefireCore/Previews/PreviewParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,44 @@ import SwiftSyntax
final class PreviewParser: SyntaxVisitor {
private(set) var body: String?
private(set) var properties: [String] = []
private(set) var displayName: String?
private(set) var traits: [String]?
private(set) var arguments: String?
private(set) var argumentPattern: String?

enum Constants {
static let previewable = "Previewable"
static let previewMacro = "Preview"
}

init() {
super.init(viewMode: .sourceAccurate)
}

var propertiesSource: String? {
guard let firstProperty = properties.first else { return nil }

return properties.dropFirst().reduce(firstProperty) {
"\($0)\n\($1)"
}
}

override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind {
guard node.macroName.text == Constants.previewMacro else { return .visitChildren }

collectMacroData(arguments: node.arguments, trailingClosure: node.trailingClosure)

return .visitChildren
}

override func visit(_ node: MacroExpansionDeclSyntax) -> SyntaxVisitorContinueKind {
guard node.macroName.text == Constants.previewMacro else { return .visitChildren }

collectMacroData(arguments: node.arguments, trailingClosure: node.trailingClosure)

return .visitChildren
}

override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind {
guard node.attributes.contain(matchingName: Constants.previewable) else {
appendItemToBody(node)
Expand Down Expand Up @@ -42,6 +71,69 @@ private extension PreviewParser {
body = "\(node.trimmed)"
}
}

func collectMacroData(arguments macroArgumentList: LabeledExprListSyntax, trailingClosure: ClosureExprSyntax?) {
let macroArguments = Array(macroArgumentList)

if let firstArgument = macroArguments.first,
firstArgument.label == nil {
displayName = Self.stringLiteralValue(from: firstArgument.expression)
}

traits = Self.traits(from: macroArguments)

arguments = macroArguments.first(where: { $0.label?.text == "arguments" }).map {
"\($0.expression.trimmed)"
}

argumentPattern = trailingClosure.flatMap(Self.argumentPattern)
}

static func stringLiteralValue(from expression: ExprSyntax) -> String? {
guard let literal = expression.as(StringLiteralExprSyntax.self) else { return nil }

let segments = literal.segments.compactMap { $0.as(StringSegmentSyntax.self)?.content.text }
guard segments.count == literal.segments.count else { return nil }

return segments.joined()
}

static func traits(from arguments: [LabeledExprSyntax]) -> [String]? {
guard let traitsIndex = arguments.firstIndex(where: { $0.label?.text == "traits" }) else {
return nil
}

var traits = ["\(arguments[traitsIndex].expression.trimmed)"]
var index = arguments.index(after: traitsIndex)

while index < arguments.endIndex, arguments[index].label == nil {
traits.append("\(arguments[index].expression.trimmed)")
index = arguments.index(after: index)
}

return traits
}

static func argumentPattern(from closure: ClosureExprSyntax) -> String? {
guard let parameterClause = closure.signature?.parameterClause else { return nil }

let names: [String]
switch parameterClause {
case .simpleInput(let parameters):
names = parameters.map { $0.name.text }
case .parameterClause(let parameterClause):
names = parameterClause.parameters.map { ($0.secondName ?? $0.firstName).text }
}

let filteredNames = names.filter { !$0.isEmpty && $0 != "_" }
guard !filteredNames.isEmpty else { return nil }

if filteredNames.count == 1 {
return filteredNames[0]
} else {
return "(\(filteredNames.joined(separator: ", ")))"
}
}
}

private extension AttributeListSyntax {
Expand Down
Loading