Skip to content

Commit 9388cb0

Browse files
Merge pull request #5 from xinnjie/feat-async-parse
feat: Make`DiffParser.parse` async to offload it from MainActor
2 parents 515004e + 769b6e4 commit 9388cb0

File tree

5 files changed

+236
-10
lines changed

5 files changed

+236
-10
lines changed

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ let package = Package(
1717
targets: [
1818
.target(
1919
name: "gitdiff"
20+
),
21+
.testTarget(
22+
name: "gitdiffTests",
23+
dependencies: ["gitdiff"]
2024
)
2125
]
2226
)

Sources/gitdiff/Core/DiffParser.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,20 @@ class DiffParser {
1515
/// Parses git diff text into structured file objects.
1616
/// - Parameter diffText: Raw git diff output
1717
/// - Returns: Array of parsed diff files
18-
static func parse(_ diffText: String) -> [DiffFile] {
18+
static func parse(_ diffText: String) async throws -> [DiffFile] {
1919
let lines = diffText.components(separatedBy: .newlines)
2020
var files: [DiffFile] = []
2121
var currentFileLines: [String] = []
2222
var i = 0
2323

2424
while i < lines.count {
25+
try Task.checkCancellation()
2526
let line = lines[i]
2627

2728
if line.hasPrefix("diff --git") {
2829
/// Process previous file if exists
2930
if !currentFileLines.isEmpty {
30-
if let file = parseFile(currentFileLines) {
31+
if let file = try await parseFile(currentFileLines) {
3132
files.append(file)
3233
}
3334
currentFileLines = []
@@ -42,7 +43,7 @@ class DiffParser {
4243

4344
/// Process last file
4445
if !currentFileLines.isEmpty {
45-
if let file = parseFile(currentFileLines) {
46+
if let file = try await parseFile(currentFileLines) {
4647
files.append(file)
4748
}
4849
}
@@ -53,7 +54,7 @@ class DiffParser {
5354
/// Parses a single file's diff lines.
5455
/// - Parameter lines: Lines belonging to a single file diff
5556
/// - Returns: Parsed file object or nil if invalid
56-
private static func parseFile(_ lines: [String]) -> DiffFile? {
57+
private static func parseFile(_ lines: [String]) async throws -> DiffFile? {
5758
guard !lines.isEmpty else { return nil }
5859

5960
var oldPath = ""
@@ -65,6 +66,7 @@ class DiffParser {
6566

6667
/// Parse file header
6768
while i < lines.count {
69+
try Task.checkCancellation()
6870
let line = lines[i]
6971

7072
if line.hasPrefix("diff --git") {

Sources/gitdiff/Views/DiffRenderer.swift

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,26 @@ public struct DiffRenderer: View {
2323

2424
@Environment(\.diffConfiguration) private var configuration
2525

26-
private var parsedFiles: [DiffFile] {
27-
DiffParser.parse(diffText)
28-
}
26+
@State private var parsedFiles: [DiffFile]? = nil
2927

3028
public init(diffText: String) {
3129
self.diffText = diffText
3230
}
3331

3432
public var body: some View {
3533
ScrollView {
36-
if parsedFiles.isEmpty {
34+
if parsedFiles == nil {
35+
VStack(spacing: 12) {
36+
ProgressView("Parsing diff…")
37+
.progressViewStyle(CircularProgressViewStyle())
38+
.tint(.accentColor)
39+
Text("Large diffs may take a moment.")
40+
.font(.caption)
41+
.foregroundColor(.secondary)
42+
}
43+
.padding()
44+
.frame(maxWidth: .infinity, maxHeight: .infinity)
45+
} else if let files = parsedFiles, files.isEmpty {
3746
VStack(spacing: 20) {
3847
Image(systemName: "doc.text.magnifyingglass")
3948
.font(.system(size: 50))
@@ -51,16 +60,19 @@ public struct DiffRenderer: View {
5160
}
5261
.padding()
5362
.frame(maxWidth: .infinity, maxHeight: .infinity)
54-
} else {
63+
} else if let files = parsedFiles {
5564
VStack(spacing: 16) {
56-
ForEach(parsedFiles) { file in
65+
ForEach(files) { file in
5766
DiffFileView(file: file)
5867
}
5968
}
6069
.padding()
6170
}
6271
}
6372
.background(Color.appBackground)
73+
.task(id: diffText) {
74+
self.parsedFiles = try? await DiffParser.parse(diffText)
75+
}
6476
}
6577
}
6678

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import gitdiff
5+
6+
struct DiffParserBenchmarks {
7+
@Test
8+
func benchmarkLargeMultiHunkParse() async throws {
9+
// Adjust these to stress the parser; keep runtime reasonable for CI
10+
let hunks = 1000
11+
let linesPerHunk = 400
12+
let iterations = 3
13+
14+
let diff = makeLargeMultiHunkDiff(hunks: hunks, linesPerHunk: linesPerHunk)
15+
16+
// Warm-up
17+
_ = try await DiffParser.parse(diff)
18+
19+
let clock = ContinuousClock()
20+
var totals: [Duration] = []
21+
22+
for _ in 0..<iterations {
23+
let duration = try await clock.measure {
24+
let files = try await DiffParser.parse(diff)
25+
#expect(!files.isEmpty)
26+
}
27+
totals.append(duration)
28+
}
29+
30+
// Report results
31+
let nanos = totals.map { $0.components.attoseconds / 1_000_000_000 } // Duration printing hack
32+
// Fallback formatting using Double seconds from Duration
33+
func seconds(_ d: Duration) -> Double {
34+
Double(d.components.seconds) + Double(d.components.attoseconds) / 1e18
35+
}
36+
let secs = totals.map(seconds)
37+
let avg = secs.reduce(0, +) / Double(secs.count)
38+
let minT = secs.min() ?? avg
39+
let maxT = secs.max() ?? avg
40+
41+
print(
42+
"DiffParser benchmark (hunks=\(hunks), linesPerHunk=\(linesPerHunk), iters=\(iterations))")
43+
print(
44+
String(
45+
format: " times: %@", secs.map { String(format: "%.4fs", $0) }.joined(separator: ", ")))
46+
print(String(format: " avg: %.4fs min: %.4fs max: %.4fs", avg, minT, maxT))
47+
}
48+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import Testing
2+
3+
@testable import gitdiff
4+
5+
internal func makeLargeMultiHunkDiff(hunks: Int, linesPerHunk: Int) -> String {
6+
var parts: [String] = []
7+
parts.append("diff --git a/large.txt b/large.txt")
8+
parts.append("index 7777777..8888888 100644")
9+
parts.append("--- a/large.txt")
10+
parts.append("+++ b/large.txt")
11+
var oldStart = 1
12+
var newStart = 1
13+
for _ in 0..<hunks {
14+
parts.append("@@ -\(oldStart),\(linesPerHunk) +\(newStart),\(linesPerHunk) @@")
15+
// Add alternating context/removed/added to keep parser busy
16+
for j in 0..<linesPerHunk {
17+
if j % 3 == 0 {
18+
parts.append(" context line \(j)")
19+
oldStart += 1
20+
newStart += 1
21+
} else if j % 3 == 1 {
22+
parts.append("-removed line \(j)")
23+
oldStart += 1
24+
} else {
25+
parts.append("+added line \(j)")
26+
newStart += 1
27+
}
28+
}
29+
}
30+
return parts.joined(separator: "\n")
31+
}
32+
struct DiffParserTests {
33+
// MARK: - Helpers
34+
private func makeSimpleSingleHunkDiff() -> String {
35+
return """
36+
diff --git a/foo.txt b/foo.txt
37+
index 1111111..2222222 100644
38+
--- a/foo.txt
39+
+++ b/foo.txt
40+
@@ -1,2 +1,3 @@
41+
line1
42+
-line2
43+
+line2 changed
44+
+line3
45+
"""
46+
}
47+
48+
private func makeTwoHunksDiff() -> String {
49+
return """
50+
diff --git a/bar.txt b/bar.txt
51+
index 3333333..4444444 100644
52+
--- a/bar.txt
53+
+++ b/bar.txt
54+
@@ -1,2 +1,2 @@
55+
a
56+
-b
57+
+B
58+
@@ -5,2 +5,3 @@
59+
five
60+
-six
61+
+six!
62+
+seven
63+
"""
64+
}
65+
66+
private func makeBinaryDiff() -> String {
67+
return """
68+
diff --git a/bin/file.bin b/bin/file.bin
69+
index abcdef1..abcdef2 100644
70+
Binary files a/bin/file.bin and b/bin/file.bin differ
71+
"""
72+
}
73+
74+
private func makeRenameDiff() -> String {
75+
return """
76+
diff --git a/old.txt b/new.txt
77+
similarity index 100%
78+
rename from old.txt
79+
rename to new.txt
80+
index 5555555..6666666 100644
81+
--- a/old.txt
82+
+++ b/new.txt
83+
"""
84+
}
85+
86+
// MARK: - Tests
87+
88+
@Test
89+
func testParseEmptyReturnsEmpty() async throws {
90+
let files = try await DiffParser.parse("")
91+
#expect(files.count == 0)
92+
}
93+
94+
@Test
95+
func testParseSingleFileSingleHunk() async throws {
96+
let diff = makeSimpleSingleHunkDiff()
97+
let files = try await DiffParser.parse(diff)
98+
#expect(files.count == 1)
99+
let file = try #require(files.first)
100+
#expect(file.oldPath == "foo.txt")
101+
#expect(file.newPath == "foo.txt")
102+
#expect(file.isBinary == false)
103+
#expect(file.isRenamed == false)
104+
#expect(file.hunks.count == 1)
105+
let hunk = try #require(file.hunks.first)
106+
#expect(hunk.header.trimmingCharacters(in: .whitespaces) == "@@ -1,2 +1,3 @@")
107+
#expect(hunk.lines.count == 4)
108+
#expect(hunk.lines[0].type == .context)
109+
#expect(hunk.lines[0].content == "line1")
110+
#expect(hunk.lines[1].type == .removed)
111+
#expect(hunk.lines[1].content == "line2")
112+
#expect(hunk.lines[2].type == .added)
113+
#expect(hunk.lines[2].content == "line2 changed")
114+
#expect(hunk.lines[3].type == .added)
115+
#expect(hunk.lines[3].content == "line3")
116+
}
117+
118+
@Test
119+
func testParseMultipleHunksInOneFile() async throws {
120+
let diff = makeTwoHunksDiff()
121+
let files = try await DiffParser.parse(diff)
122+
#expect(files.count == 1)
123+
let file = try #require(files.first)
124+
#expect(file.hunks.count == 2)
125+
}
126+
127+
@Test
128+
func testParseBinaryFile() async throws {
129+
let diff = makeBinaryDiff()
130+
let files = try await DiffParser.parse(diff)
131+
#expect(files.count == 1)
132+
let file = try #require(files.first)
133+
#expect(file.isBinary)
134+
#expect(file.hunks.count == 0)
135+
}
136+
137+
@Test
138+
func testParseRename() async throws {
139+
let diff = makeRenameDiff()
140+
let files = try await DiffParser.parse(diff)
141+
#expect(files.count == 1)
142+
let file = try #require(files.first)
143+
#expect(file.isRenamed)
144+
#expect(file.oldPath == "old.txt")
145+
#expect(file.newPath == "new.txt")
146+
}
147+
148+
@Test
149+
func testCancellationThrowsCancellationError() async throws {
150+
// Build a large diff to ensure the task doesn't finish instantly
151+
let diff = makeLargeMultiHunkDiff(hunks: 40, linesPerHunk: 30)
152+
let task = Task { () -> [DiffFile] in try await DiffParser.parse(diff) }
153+
// Yield and then cancel shortly after to trigger cooperative cancellation
154+
await Task.yield()
155+
task.cancel()
156+
await #expect(throws: CancellationError.self) {
157+
_ = try await task.value
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)