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
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ let package = Package(
targets: [
.target(
name: "gitdiff"
),
.testTarget(
name: "gitdiffTests",
dependencies: ["gitdiff"]
)
]
)
10 changes: 6 additions & 4 deletions Sources/gitdiff/Core/DiffParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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)
}
}
Expand All @@ -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 = ""
Expand All @@ -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") {
Expand Down
24 changes: 18 additions & 6 deletions Sources/gitdiff/Views/DiffRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,26 @@ 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
}

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))
Expand All @@ -51,16 +60,19 @@ 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)
}
}
.padding()
}
}
.background(Color.appBackground)
.task(id: diffText) {
self.parsedFiles = try? await DiffParser.parse(diffText)
}
}
}

Expand Down
48 changes: 48 additions & 0 deletions Tests/gitdiffTests/DiffParserBenchmarks.swift
Original file line number Diff line number Diff line change
@@ -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..<iterations {
let duration = try await clock.measure {
let files = try await DiffParser.parse(diff)
#expect(!files.isEmpty)
}
totals.append(duration)
}

// Report results
let nanos = totals.map { $0.components.attoseconds / 1_000_000_000 } // Duration printing hack
// Fallback formatting using Double seconds from Duration
func seconds(_ d: Duration) -> 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))
}
}
160 changes: 160 additions & 0 deletions Tests/gitdiffTests/DiffParserTests.swift
Original file line number Diff line number Diff line change
@@ -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..<hunks {
parts.append("@@ -\(oldStart),\(linesPerHunk) +\(newStart),\(linesPerHunk) @@")
// Add alternating context/removed/added to keep parser busy
for j in 0..<linesPerHunk {
if j % 3 == 0 {
parts.append(" context line \(j)")
oldStart += 1
newStart += 1
} else if j % 3 == 1 {
parts.append("-removed line \(j)")
oldStart += 1
} else {
parts.append("+added line \(j)")
newStart += 1
}
}
}
return parts.joined(separator: "\n")
}
struct DiffParserTests {
// MARK: - Helpers
private func makeSimpleSingleHunkDiff() -> 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
}
}
}