Skip to content

Commit e3ec534

Browse files
authored
Merge branch 'main' into supportMint
2 parents 1a8ed63 + 001157d commit e3ec534

File tree

18 files changed

+537
-42
lines changed

18 files changed

+537
-42
lines changed

.github/workflows/swift.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ jobs:
2020
include:
2121
- xcode: "Xcode_16.0"
2222
runsOn: macos-15
23-
- xcode: "Xcode_16.2"
23+
- xcode: "Xcode_26.0"
2424
runsOn: macos-15
2525
steps:
2626
- uses: actions/checkout@v4
27+
2728
- name: Select Xcode
2829
run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app
30+
2931
- name: Build Prefire
3032
run: make build
33+
3134
- name: Run PrefireExecutable Tests
3235
run: make test

Binaries/PrefireBinary.artifactbundle/info.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
"artifacts": {
44
"PrefireBinary": {
55
"type": "executable",
6-
"version": "5.0.1",
6+
"version": "5.1.0",
77
"variants": [
88
{
9-
"path": "prefire-5.0.1-macos/bin/prefire",
9+
"path": "prefire-5.1.0-macos/bin/prefire",
1010
"supportedTriples": ["x86_64-apple-macosx", "arm64-apple-macosx"]
1111
},
1212
]

Binaries/PrefireBinary.artifactbundle/prefire-5.0.1-macos/bin/prefire renamed to Binaries/PrefireBinary.artifactbundle/prefire-5.1.0-macos/bin/prefire

38.7 MB
Binary file not shown.

Configuration.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ test_configuration:
1313
simulator_device: "iPhone15,2"
1414
required_os: 16
1515
preview_default_enabled: true
16+
use_grouped_snapshots: true
1617
sources:
1718
- ${PROJECT_DIR}/Sources/
1819
snapshot_devices:
@@ -40,17 +41,18 @@ playbook_configuration:
4041
4142
| Key | Description |
4243
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
43-
| `target` | Target name used for snapshot generation.Default: *FirstTarget* |
44-
| `test_target_path` | Path to unit test directory. Snapshots will be written to its `__Snapshots__` folder.Default: target name folder |
45-
| `test_file_path` | Output file path for generated tests.Default: DerivedData or resolved via plugin |
46-
| `template_file_path` | Custom template path relative to target. Optional.Defaults:‣ *PreviewTests.stencil* for test plugin‣ *PreviewModels.stencil* for playbook plugin |
47-
| `simulator_device` | Device identifier used to run tests (e.g. `iPhone15,2`). Optional |
48-
| `required_os` | Minimal iOS version required for preview rendering. Optional |
49-
| `snapshot_devices` | List of logical snapshot "targets" (used as trait collections).Each will snapshot separately. Optional |
50-
| `preview_default_enabled` | Should all detected previews be included by default?Set `false` if you want to require `.prefireEnabled()` manually.Default: `true` |
51-
| `sources` | List of Swift files or folders to scan for previews.Defaults to inferred from the target |
52-
| `imports` | Extra imports added to the generated test or playbook file |
53-
| `testable_imports` | Extra `@testable` imports added to allow test visibility |
44+
| `target` | Target name used for snapshot generation. Default: *FirstTarget* |
45+
| `test_target_path` | Path to unit test directory. Snapshots will be written to its `__Snapshots__` folder. Default: target name folder |
46+
| `test_file_path` | Output file path for generated tests. Default: DerivedData or resolved via plugin |
47+
| `template_file_path` | Custom template path relative to target. Optional. Defaults:‣ *PreviewTests.stencil* for test plugin‣ *PreviewModels.stencil* for playbook plugin|
48+
| `simulator_device` | Device identifier used to run tests (e.g. `iPhone15,2`). Optional |
49+
| `required_os` | Minimal iOS version required for preview rendering. Optional |
50+
| `snapshot_devices` | List of logical snapshot "targets" (used as trait collections). Each will snapshot separately. Optional |
51+
| `preview_default_enabled` | Should all detected previews be included by default? Set `false` if you want to require `.prefireEnabled()` manually. Default: `true` |
52+
| `use_grouped_snapshots` | Generate a single test file with all previews (`true`) or separate test files per source file (`false`). When `false`, use `{PREVIEW_FILE_NAME}` placeholder in `test_file_path`. Default: `true` |
53+
| `sources` | List of Swift files or folders to scan for previews. Defaults to inferred from the target |
54+
| `imports` | Extra imports added to the generated test or playbook file |
55+
| `testable_imports` | Extra `@testable` imports added to allow test visibility |
5456

5557
---
5658

@@ -61,4 +63,4 @@ Prefire will use these settings when generating files either via:
6163
- CLI: `prefire tests`, `prefire playbook`
6264
- Plugin: attached to test or main targets in Xcode/SwiftPM
6365

64-
To support different configurations per module, you may also use multiple `.prefire.yml` files — one per package, if needed.
66+
To support different configurations per module, you may also use multiple `.prefire.yml` files — one per package, if needed.

Example/.prefire.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ test_configuration:
88
- imports:
99
- UIKit
1010
- Foundation
11+
- use_grouped_snapshots: false
1112

1213
playbook_configuration:
1314
- imports:

Example/PrefireExampleTests/PrefireExampleTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ import Prefire
33

44
// MARK: - Generated Tests
55

6-
let tests = PreviewTests.self
6+
private let tests = _TestFile.self
7+
8+
private struct _TestFile { }

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
MAKEFLAGS += --silent
2+
23
FOLDER=$(shell cd Binaries/PrefireBinary.artifactbundle/; ls -d */|head -n 1)
34
CUR_VERSION=$(shell echo $(FOLDER) | cut -d "-" -f 2)
45

6+
.PHONY: build binary test update archive
7+
58
build:
6-
set -o pipefail && xcodebuild -scheme Prefire -destination 'generic/platform=iOS'
9+
set -o pipefail && xcodebuild \
10+
-scheme Prefire \
11+
-sdk iphonesimulator \
12+
-destination 'generic/platform=iOS Simulator' \
13+
-configuration Release \
14+
-skipMacroValidation \
15+
-skipPackagePluginValidation \
16+
build
717

818
binary:
919
(cd PrefireExecutable; swift build -c release --arch arm64 --arch x86_64)

PrefireExecutable/Sources/PrefireCore/PrefireGenerator.swift

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ public enum PrefireGenerator {
1414
arguments: [String: NSObject],
1515
inlineTemplate: String,
1616
defaultEnabled: Bool,
17-
cacheDir: Path? = nil
17+
cacheDir: Path? = nil,
18+
useGroupedSnapshots: Bool
1819
) async throws {
1920
startTime = Date()
2021

@@ -34,6 +35,9 @@ public enum PrefireGenerator {
3435

3536
let fileContents: [(Path, String)] = try swiftFiles.map { ($0, try $0.read(.utf8)) }
3637

38+
// If use grouped is false, we geneate one file per tests
39+
// and use the template as output replacing the "{PREVIEW_FILE_NAME}" with the name of the file
40+
3741
let manager = PrefireCacheManager(version: version, cacheBasePath: cacheDir)
3842
let (types, previews) = try await manager.loadOrGenerate(
3943
sources: fileContents.map { $0.0 },
@@ -71,18 +75,93 @@ public enum PrefireGenerator {
7175

7276
let previewModels = previews
7377
.sorted { $0.key > $1.key }
74-
.compactMap { RawPreviewModel(from: $0.value, filename: $0.key) }
75-
.map { $0.makeStencilDict() }
76-
77-
var arguments = arguments
78-
arguments["previewsMacrosDict"] = previewModels as NSArray
78+
.compactMap { entry -> [String: Any?]? in
79+
guard let model = RawPreviewModel(from: entry.value, filename: entry.key) else { return nil }
80+
var dict = model.makeStencilDict()
81+
// Add the source filename for ungrouped generation
82+
dict["sourceFileName"] = extractFileNameFromKey(entry.key)
83+
return dict
84+
}
7985

8086
let parserResult = FileParserResult(path: nil, module: nil, types: types.types, functions: [], typealiases: [])
81-
82-
try renderAndWrite(parserResult: parserResult, inlineTemplate: inlineTemplate, output: output, arguments: arguments)
87+
88+
if useGroupedSnapshots {
89+
// Generate one file with all previews
90+
var arguments = arguments
91+
arguments["previewsMacrosDict"] = previewModels as NSArray
92+
93+
// For grouped snapshots, replace {PREVIEW_FILE_NAME} with "Preview" to maintain current class name
94+
let customizedTemplate = inlineTemplate.replacingOccurrences(of: "{PREVIEW_FILE_NAME}", with: "Preview")
95+
96+
try renderAndWrite(parserResult: parserResult, inlineTemplate: customizedTemplate, output: output, arguments: arguments)
97+
} else {
98+
// Generate one file per source file containing previews
99+
try generateUngroupedFiles(
100+
previewModels: previewModels,
101+
parserResult: parserResult,
102+
inlineTemplate: inlineTemplate,
103+
output: output,
104+
arguments: arguments
105+
)
106+
}
83107

84108
Logger.info("✅ Generation completed in \(startTime.distance(to: Date()).formatted())")
85109
}
110+
111+
private static func generateUngroupedFiles(
112+
previewModels: [[String: Any?]],
113+
parserResult: FileParserResult,
114+
inlineTemplate: String,
115+
output: Path,
116+
arguments: [String: NSObject]
117+
) throws {
118+
// Group preview models by their source file name
119+
let groupedByFile = Dictionary(grouping: previewModels) { previewModel -> String in
120+
return previewModel["sourceFileName"] as? String ?? "Unknown"
121+
}
122+
123+
Logger.info("📝 Generating \(groupedByFile.count) separate test files...")
124+
125+
for (fileName, models) in groupedByFile {
126+
guard fileName != "Unknown" else { continue }
127+
128+
// Replace the placeholder in the output path
129+
let outputPath = replacePreviewFileName(in: output, with: fileName)
130+
131+
var fileArguments = arguments
132+
fileArguments["previewsMacrosDict"] = models as NSArray
133+
134+
// Replace the placeholder in the template as well
135+
let customizedTemplate = inlineTemplate.replacingOccurrences(of: "{PREVIEW_FILE_NAME}", with: fileName)
136+
137+
Logger.info("🖋 Rendering template for \(fileName)...")
138+
try renderAndWrite(
139+
parserResult: parserResult,
140+
inlineTemplate: customizedTemplate,
141+
output: outputPath,
142+
arguments: fileArguments
143+
)
144+
}
145+
}
146+
147+
private static func extractFileNameFromKey(_ key: String) -> String {
148+
// Key format is "FileName_index", extract just the file name part
149+
let components = key.components(separatedBy: "_")
150+
guard !components.isEmpty else { return key }
151+
152+
// If the last component is a number, it's likely an index
153+
if components.count > 1 && Int(components.last!) != nil {
154+
return components.dropLast().joined(separator: "_")
155+
}
156+
157+
return key
158+
}
159+
160+
private static func replacePreviewFileName(in path: Path, with fileName: String) -> Path {
161+
let pathString = path.string
162+
let updatedPath = pathString.replacingOccurrences(of: "{PREVIEW_FILE_NAME}", with: fileName)
163+
return Path(updatedPath)
164+
}
86165

87166
private static func renderAndWrite(
88167
parserResult: FileParserResult,

PrefireExecutable/Sources/PrefireCore/Previews/RawPreviewModel.swift

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import Foundation
22

33
struct RawPreviewModel {
44
var displayName: String
5-
var traits: String
5+
var traits: [String]
66
var body: String
77
var properties: String?
88

99
var isScreen: Bool {
10-
traits == Constants.defaultTrait
10+
traits.contains(Constants.defaultTrait)
1111
}
1212
}
1313

@@ -44,11 +44,15 @@ extension RawPreviewModel {
4444
var previewTrait: String?
4545
if let range = firstLine.range(of: Markers.traits) {
4646
let substring = firstLine[range.upperBound...]
47-
if let endIndex = substring.firstIndex(of: ")") {
47+
let endIndex = Self.findTraitsEndIndex(in: substring)
48+
if let endIndex = endIndex {
4849
previewTrait = String(substring[..<endIndex])
4950
}
5051
}
51-
self.traits = previewTrait ?? Constants.defaultTrait
52+
53+
// Traits can be functions like .myTraitt("one", 2), .device
54+
// We can have both at the same time separated by comma
55+
self.traits = Self.parseTraits(from: previewTrait)
5256

5357
for (index, line) in lines.enumerated() {
5458
// Search for the line with `@Previewable` macro
@@ -64,6 +68,116 @@ extension RawPreviewModel {
6468

6569
self.body = lines.joined(separator: "\n")
6670
}
71+
72+
/// Parse traits from the raw trait string
73+
/// Handles single traits, comma-separated traits, and function-style traits
74+
private static func parseTraits(from rawTraits: String?) -> [String] {
75+
guard let rawTraits = rawTraits?.trimmingCharacters(in: .whitespacesAndNewlines),
76+
!rawTraits.isEmpty else {
77+
return [Constants.defaultTrait]
78+
}
79+
80+
// Pre-allocate array with estimated capacity for better performance
81+
var traits: [String] = []
82+
traits.reserveCapacity(4) // Most common case: 1-3 traits
83+
84+
let startIndex = rawTraits.startIndex
85+
let endIndex = rawTraits.endIndex
86+
var currentStart = startIndex
87+
var currentIndex = startIndex
88+
var parenthesesDepth = 0
89+
var insideQuotes = false
90+
var quoteChar: Character?
91+
92+
while currentIndex < endIndex {
93+
let char = rawTraits[currentIndex]
94+
95+
switch char {
96+
case "\"", "'":
97+
if !insideQuotes {
98+
insideQuotes = true
99+
quoteChar = char
100+
} else if char == quoteChar {
101+
insideQuotes = false
102+
quoteChar = nil
103+
}
104+
case "(":
105+
if !insideQuotes {
106+
parenthesesDepth += 1
107+
}
108+
case ")":
109+
if !insideQuotes {
110+
parenthesesDepth -= 1
111+
}
112+
case ",":
113+
if !insideQuotes && parenthesesDepth == 0 {
114+
// This comma is a trait separator
115+
let traitSubstring = rawTraits[currentStart..<currentIndex]
116+
let trimmedTrait = traitSubstring.trimmingCharacters(in: .whitespacesAndNewlines)
117+
if !trimmedTrait.isEmpty {
118+
traits.append(String(trimmedTrait))
119+
}
120+
currentStart = rawTraits.index(after: currentIndex)
121+
}
122+
default:
123+
break
124+
}
125+
126+
currentIndex = rawTraits.index(after: currentIndex)
127+
}
128+
129+
// Add the last trait
130+
let lastTraitSubstring = rawTraits[currentStart..<endIndex]
131+
let trimmedLastTrait = lastTraitSubstring.trimmingCharacters(in: .whitespacesAndNewlines)
132+
if !trimmedLastTrait.isEmpty {
133+
traits.append(String(trimmedLastTrait))
134+
}
135+
136+
return traits.isEmpty ? [Constants.defaultTrait] : traits
137+
}
138+
139+
/// Find the correct end index for traits, respecting parentheses balance
140+
private static func findTraitsEndIndex(in substring: Substring) -> String.Index? {
141+
var parenthesesDepth = 0
142+
var insideQuotes = false
143+
var quoteChar: Character?
144+
var currentIndex = substring.startIndex
145+
let endIndex = substring.endIndex
146+
147+
while currentIndex < endIndex {
148+
let char = substring[currentIndex]
149+
150+
switch char {
151+
case "\"", "'":
152+
if !insideQuotes {
153+
insideQuotes = true
154+
quoteChar = char
155+
} else if char == quoteChar {
156+
insideQuotes = false
157+
quoteChar = nil
158+
}
159+
case "(":
160+
if !insideQuotes {
161+
parenthesesDepth += 1
162+
}
163+
case ")":
164+
if !insideQuotes {
165+
if parenthesesDepth == 0 {
166+
// This is the closing parenthesis of the #Preview call
167+
return currentIndex
168+
}
169+
parenthesesDepth -= 1
170+
}
171+
default:
172+
break
173+
}
174+
175+
currentIndex = substring.index(after: currentIndex)
176+
}
177+
178+
// If we didn't find a balanced closing parenthesis, return nil
179+
return nil
180+
}
67181
}
68182

69183
extension RawPreviewModel {
@@ -79,7 +193,8 @@ extension RawPreviewModel {
79193
"componentTestName": componentTestName,
80194
"isScreen": isScreen,
81195
"body": body,
82-
"properties": properties
196+
"properties": properties,
197+
"traits": traits
83198
].filter({ $0.value != nil })
84199
}
85200
}

0 commit comments

Comments
 (0)