From 769b6e400e206056479dc9b22e71fa53bd475cff Mon Sep 17 00:00:00 2001 From: xinnjie Date: Sun, 14 Sep 2025 02:27:46 +0800 Subject: [PATCH] feat: make DiffParser.parse async to offload it from MainActor --- Package.swift | 4 + Sources/gitdiff/Core/DiffParser.swift | 10 +- Sources/gitdiff/Views/DiffRenderer.swift | 24 ++- Tests/gitdiffTests/DiffParserBenchmarks.swift | 48 ++++++ Tests/gitdiffTests/DiffParserTests.swift | 160 ++++++++++++++++++ 5 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 Tests/gitdiffTests/DiffParserBenchmarks.swift create mode 100644 Tests/gitdiffTests/DiffParserTests.swift diff --git a/Package.swift b/Package.swift index 203105d..c8a0f8b 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,10 @@ let package = Package( targets: [ .target( name: "gitdiff" + ), + .testTarget( + name: "gitdiffTests", + dependencies: ["gitdiff"] ) ] ) diff --git a/Sources/gitdiff/Core/DiffParser.swift b/Sources/gitdiff/Core/DiffParser.swift index bd2100b..5ff09ac 100644 --- a/Sources/gitdiff/Core/DiffParser.swift +++ b/Sources/gitdiff/Core/DiffParser.swift @@ -15,19 +15,20 @@ class DiffParser { /// Parses git diff text into structured file objects. /// - Parameter diffText: Raw git diff output /// - Returns: Array of parsed diff files - static func parse(_ diffText: String) -> [DiffFile] { + static func parse(_ diffText: String) async throws -> [DiffFile] { let lines = diffText.components(separatedBy: .newlines) var files: [DiffFile] = [] var currentFileLines: [String] = [] var i = 0 while i < lines.count { + try Task.checkCancellation() let line = lines[i] if line.hasPrefix("diff --git") { /// Process previous file if exists if !currentFileLines.isEmpty { - if let file = parseFile(currentFileLines) { + if let file = try await parseFile(currentFileLines) { files.append(file) } currentFileLines = [] @@ -42,7 +43,7 @@ class DiffParser { /// Process last file if !currentFileLines.isEmpty { - if let file = parseFile(currentFileLines) { + if let file = try await parseFile(currentFileLines) { files.append(file) } } @@ -53,7 +54,7 @@ class DiffParser { /// Parses a single file's diff lines. /// - Parameter lines: Lines belonging to a single file diff /// - Returns: Parsed file object or nil if invalid - private static func parseFile(_ lines: [String]) -> DiffFile? { + private static func parseFile(_ lines: [String]) async throws -> DiffFile? { guard !lines.isEmpty else { return nil } var oldPath = "" @@ -65,6 +66,7 @@ class DiffParser { /// Parse file header while i < lines.count { + try Task.checkCancellation() let line = lines[i] if line.hasPrefix("diff --git") { diff --git a/Sources/gitdiff/Views/DiffRenderer.swift b/Sources/gitdiff/Views/DiffRenderer.swift index 64ade4e..1b12ac6 100644 --- a/Sources/gitdiff/Views/DiffRenderer.swift +++ b/Sources/gitdiff/Views/DiffRenderer.swift @@ -23,9 +23,7 @@ public struct DiffRenderer: View { @Environment(\.diffConfiguration) private var configuration - private var parsedFiles: [DiffFile] { - DiffParser.parse(diffText) - } + @State private var parsedFiles: [DiffFile]? = nil public init(diffText: String) { self.diffText = diffText @@ -33,7 +31,18 @@ public struct DiffRenderer: View { public var body: some View { ScrollView { - if parsedFiles.isEmpty { + if parsedFiles == nil { + VStack(spacing: 12) { + ProgressView("Parsing diff…") + .progressViewStyle(CircularProgressViewStyle()) + .tint(.accentColor) + Text("Large diffs may take a moment.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let files = parsedFiles, files.isEmpty { VStack(spacing: 20) { Image(systemName: "doc.text.magnifyingglass") .font(.system(size: 50)) @@ -51,9 +60,9 @@ public struct DiffRenderer: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { + } else if let files = parsedFiles { VStack(spacing: 16) { - ForEach(parsedFiles) { file in + ForEach(files) { file in DiffFileView(file: file) } } @@ -61,6 +70,9 @@ public struct DiffRenderer: View { } } .background(Color.appBackground) + .task(id: diffText) { + self.parsedFiles = try? await DiffParser.parse(diffText) + } } } diff --git a/Tests/gitdiffTests/DiffParserBenchmarks.swift b/Tests/gitdiffTests/DiffParserBenchmarks.swift new file mode 100644 index 0000000..2e9cae2 --- /dev/null +++ b/Tests/gitdiffTests/DiffParserBenchmarks.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import gitdiff + +struct DiffParserBenchmarks { + @Test + func benchmarkLargeMultiHunkParse() async throws { + // Adjust these to stress the parser; keep runtime reasonable for CI + let hunks = 1000 + let linesPerHunk = 400 + let iterations = 3 + + let diff = makeLargeMultiHunkDiff(hunks: hunks, linesPerHunk: linesPerHunk) + + // Warm-up + _ = try await DiffParser.parse(diff) + + let clock = ContinuousClock() + var totals: [Duration] = [] + + for _ in 0.. Double { + Double(d.components.seconds) + Double(d.components.attoseconds) / 1e18 + } + let secs = totals.map(seconds) + let avg = secs.reduce(0, +) / Double(secs.count) + let minT = secs.min() ?? avg + let maxT = secs.max() ?? avg + + print( + "DiffParser benchmark (hunks=\(hunks), linesPerHunk=\(linesPerHunk), iters=\(iterations))") + print( + String( + format: " times: %@", secs.map { String(format: "%.4fs", $0) }.joined(separator: ", "))) + print(String(format: " avg: %.4fs min: %.4fs max: %.4fs", avg, minT, maxT)) + } +} diff --git a/Tests/gitdiffTests/DiffParserTests.swift b/Tests/gitdiffTests/DiffParserTests.swift new file mode 100644 index 0000000..7460d5d --- /dev/null +++ b/Tests/gitdiffTests/DiffParserTests.swift @@ -0,0 +1,160 @@ +import Testing + +@testable import gitdiff + +internal func makeLargeMultiHunkDiff(hunks: Int, linesPerHunk: Int) -> String { + var parts: [String] = [] + parts.append("diff --git a/large.txt b/large.txt") + parts.append("index 7777777..8888888 100644") + parts.append("--- a/large.txt") + parts.append("+++ b/large.txt") + var oldStart = 1 + var newStart = 1 + for _ in 0.. String { + return """ + diff --git a/foo.txt b/foo.txt + index 1111111..2222222 100644 + --- a/foo.txt + +++ b/foo.txt + @@ -1,2 +1,3 @@ + line1 + -line2 + +line2 changed + +line3 + """ + } + + private func makeTwoHunksDiff() -> String { + return """ + diff --git a/bar.txt b/bar.txt + index 3333333..4444444 100644 + --- a/bar.txt + +++ b/bar.txt + @@ -1,2 +1,2 @@ + a + -b + +B + @@ -5,2 +5,3 @@ + five + -six + +six! + +seven + """ + } + + private func makeBinaryDiff() -> String { + return """ + diff --git a/bin/file.bin b/bin/file.bin + index abcdef1..abcdef2 100644 + Binary files a/bin/file.bin and b/bin/file.bin differ + """ + } + + private func makeRenameDiff() -> String { + return """ + diff --git a/old.txt b/new.txt + similarity index 100% + rename from old.txt + rename to new.txt + index 5555555..6666666 100644 + --- a/old.txt + +++ b/new.txt + """ + } + + // MARK: - Tests + + @Test + func testParseEmptyReturnsEmpty() async throws { + let files = try await DiffParser.parse("") + #expect(files.count == 0) + } + + @Test + func testParseSingleFileSingleHunk() async throws { + let diff = makeSimpleSingleHunkDiff() + let files = try await DiffParser.parse(diff) + #expect(files.count == 1) + let file = try #require(files.first) + #expect(file.oldPath == "foo.txt") + #expect(file.newPath == "foo.txt") + #expect(file.isBinary == false) + #expect(file.isRenamed == false) + #expect(file.hunks.count == 1) + let hunk = try #require(file.hunks.first) + #expect(hunk.header.trimmingCharacters(in: .whitespaces) == "@@ -1,2 +1,3 @@") + #expect(hunk.lines.count == 4) + #expect(hunk.lines[0].type == .context) + #expect(hunk.lines[0].content == "line1") + #expect(hunk.lines[1].type == .removed) + #expect(hunk.lines[1].content == "line2") + #expect(hunk.lines[2].type == .added) + #expect(hunk.lines[2].content == "line2 changed") + #expect(hunk.lines[3].type == .added) + #expect(hunk.lines[3].content == "line3") + } + + @Test + func testParseMultipleHunksInOneFile() async throws { + let diff = makeTwoHunksDiff() + let files = try await DiffParser.parse(diff) + #expect(files.count == 1) + let file = try #require(files.first) + #expect(file.hunks.count == 2) + } + + @Test + func testParseBinaryFile() async throws { + let diff = makeBinaryDiff() + let files = try await DiffParser.parse(diff) + #expect(files.count == 1) + let file = try #require(files.first) + #expect(file.isBinary) + #expect(file.hunks.count == 0) + } + + @Test + func testParseRename() async throws { + let diff = makeRenameDiff() + let files = try await DiffParser.parse(diff) + #expect(files.count == 1) + let file = try #require(files.first) + #expect(file.isRenamed) + #expect(file.oldPath == "old.txt") + #expect(file.newPath == "new.txt") + } + + @Test + func testCancellationThrowsCancellationError() async throws { + // Build a large diff to ensure the task doesn't finish instantly + let diff = makeLargeMultiHunkDiff(hunks: 40, linesPerHunk: 30) + let task = Task { () -> [DiffFile] in try await DiffParser.parse(diff) } + // Yield and then cancel shortly after to trigger cooperative cancellation + await Task.yield() + task.cancel() + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + } +}