Skip to content

Commit bb75a93

Browse files
Merge pull request #9 from tornikegomareli/feat/diff-parsing-protocol
Add DiffParsing protocol for custom diff formats
2 parents 7d86dec + e0110bb commit bb75a93

8 files changed

Lines changed: 283 additions & 30 deletions

File tree

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,46 @@ struct PullRequestView: View {
207207
- `.diffLineSpacing(_ spacing: LineSpacing)` - Set line spacing
208208
- `.diffWordWrap(_ wrap: Bool)` - Enable word wrapping
209209
- `.diffConfiguration(_ config: DiffConfiguration)` - Apply complete configuration
210+
- `.diffParser(_ parser: any DiffParsing)` - Plug in a custom parser (see below)
211+
212+
## Custom Diff Formats
213+
214+
`DiffRenderer` accepts unified-diff text by default. To consume any other format — annotated diffs, server-side payloads, JSON patches, language-server output — implement `DiffParsing` and inject it via the `.diffParser(_:)` modifier:
215+
216+
```swift
217+
import gitdiff
218+
219+
struct MyAnnotatedDiffParser: DiffParsing {
220+
let filePath: String
221+
222+
func parse(_ diffText: String) async throws -> [DiffFile] {
223+
// Map your custom format → [DiffFile] using the public initializers
224+
// on DiffFile / DiffHunk / DiffLine. The renderer doesn't care how
225+
// you produced the model.
226+
[
227+
DiffFile(
228+
oldPath: filePath,
229+
newPath: filePath,
230+
hunks: [
231+
DiffHunk(
232+
oldStart: 1, oldCount: 1, newStart: 1, newCount: 1,
233+
header: "",
234+
lines: [
235+
DiffLine(type: .removed, content: "old", oldLineNumber: 1, newLineNumber: nil),
236+
DiffLine(type: .added, content: "new", oldLineNumber: nil, newLineNumber: 1),
237+
]
238+
)
239+
]
240+
)
241+
]
242+
}
243+
}
244+
245+
// Inject — default stays `UnifiedDiffParser` if no override.
246+
DiffRenderer(diffText: myRawText)
247+
.diffParser(MyAnnotatedDiffParser(filePath: "foo.swift"))
248+
.diffTheme(.dark)
249+
```
210250

211251
## Example App
212252

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// DiffParsing.swift
3+
// gitdiff
4+
//
5+
6+
import Foundation
7+
8+
/// Strategy for turning a diff text into the renderer's domain model.
9+
///
10+
/// `DiffRenderer` reads the active parser from the environment so callers
11+
/// can plug in formats other than standard unified diff — annotated diffs,
12+
/// pre-tokenised server output, JSON patches, anything that can produce a
13+
/// `[DiffFile]`. Inject via the ``SwiftUI/View/diffParser(_:)`` modifier.
14+
///
15+
/// ```swift
16+
/// DiffRenderer(diffText: rawDiff)
17+
/// .diffParser(MyAnnotatedDiffParser(filePath: "foo.swift"))
18+
/// ```
19+
public protocol DiffParsing: Sendable {
20+
/// Parse the given diff text into the renderer's domain model.
21+
///
22+
/// - Parameter diffText: Raw input in whatever format this parser understands.
23+
/// - Returns: One `DiffFile` per file represented in the input. Returning
24+
/// an empty array causes `DiffRenderer` to show the "no content" state.
25+
/// - Throws: Re-thrown by the renderer's `.task` — usually
26+
/// `CancellationError` if the view disappears mid-parse, but custom
27+
/// parsers may surface format errors here.
28+
func parse(_ diffText: String) async throws -> [DiffFile]
29+
}
30+
31+
/// Default parser — accepts standard unified-diff text (the output of
32+
/// `git diff`, `diff -u`, `Diff.createTwoFilesPatch` from jsdiff, etc.).
33+
public struct UnifiedDiffParser: DiffParsing {
34+
public init() {}
35+
36+
public func parse(_ diffText: String) async throws -> [DiffFile] {
37+
try await DiffParser.parse(diffText)
38+
}
39+
}

Sources/gitdiff/DiffEnvironment.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ struct DiffConfigurationKey: EnvironmentKey {
55
static let defaultValue = DiffConfiguration.default
66
}
77

8+
/// Environment key for the active diff parser. Defaults to
9+
/// ``UnifiedDiffParser`` so existing callers see no change.
10+
struct DiffParserKey: EnvironmentKey {
11+
static let defaultValue: any DiffParsing = UnifiedDiffParser()
12+
}
13+
814
/// Environment extensions for diff configuration
915
extension EnvironmentValues {
1016
public var diffConfiguration: DiffConfiguration {
1117
get { self[DiffConfigurationKey.self] }
1218
set { self[DiffConfigurationKey.self] = newValue }
1319
}
20+
21+
public var diffParser: any DiffParsing {
22+
get { self[DiffParserKey.self] }
23+
set { self[DiffParserKey.self] = newValue }
24+
}
1425
}
1526

1627
// MARK: - View Modifiers
@@ -140,6 +151,16 @@ public extension View {
140151
}
141152
}
142153

154+
/// Injects a custom ``DiffParsing`` implementation so the renderer can
155+
/// consume formats other than standard unified diff (annotated diffs,
156+
/// server-side payloads, JSON patches, …). The default parser is
157+
/// ``UnifiedDiffParser``.
158+
///
159+
/// - Parameter parser: Any value conforming to ``DiffParsing``.
160+
func diffParser(_ parser: any DiffParsing) -> some View {
161+
environment(\.diffParser, parser)
162+
}
163+
143164
/// Sets content padding
144165
func diffPadding(_ padding: EdgeInsets) -> some View {
145166
transformEnvironment(\.diffConfiguration) { config in

Sources/gitdiff/Models/DiffFile.swift

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,39 @@
88

99
import Foundation
1010

11-
/// Represents a file in a git diff.
12-
struct DiffFile: Identifiable {
13-
let id = UUID()
14-
let oldPath: String
15-
let newPath: String
16-
let hunks: [DiffHunk]
17-
let isBinary: Bool
18-
let isRenamed: Bool
19-
11+
/// Represents a file in a diff.
12+
///
13+
/// The library ships a `UnifiedDiffParser` for standard unified diffs, but
14+
/// any type conforming to ``DiffParsing`` can construct `DiffFile` values
15+
/// directly — that's how consumers plug in custom diff formats (annotated
16+
/// diffs, JSON patches, server-side formats, etc.) without modifying the
17+
/// renderer.
18+
public struct DiffFile: Identifiable, Sendable {
19+
public let id: UUID
20+
public let oldPath: String
21+
public let newPath: String
22+
public let hunks: [DiffHunk]
23+
public let isBinary: Bool
24+
public let isRenamed: Bool
25+
26+
public init(
27+
id: UUID = UUID(),
28+
oldPath: String,
29+
newPath: String,
30+
hunks: [DiffHunk],
31+
isBinary: Bool = false,
32+
isRenamed: Bool = false
33+
) {
34+
self.id = id
35+
self.oldPath = oldPath
36+
self.newPath = newPath
37+
self.hunks = hunks
38+
self.isBinary = isBinary
39+
self.isRenamed = isRenamed
40+
}
41+
2042
/// Formatted name for display, showing rename if applicable.
21-
var displayName: String {
43+
public var displayName: String {
2244
if isRenamed {
2345
return "\(oldPath)\(newPath)"
2446
}

Sources/gitdiff/Models/DiffHunk.swift

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,30 @@
99
import Foundation
1010

1111
/// Represents a change section (hunk) in a diff file.
12-
struct DiffHunk: Identifiable {
13-
let id = UUID()
14-
let oldStart: Int
15-
let oldCount: Int
16-
let newStart: Int
17-
let newCount: Int
18-
let header: String
19-
let lines: [DiffLine]
12+
public struct DiffHunk: Identifiable, Sendable {
13+
public let id: UUID
14+
public let oldStart: Int
15+
public let oldCount: Int
16+
public let newStart: Int
17+
public let newCount: Int
18+
public let header: String
19+
public let lines: [DiffLine]
20+
21+
public init(
22+
id: UUID = UUID(),
23+
oldStart: Int,
24+
oldCount: Int,
25+
newStart: Int,
26+
newCount: Int,
27+
header: String,
28+
lines: [DiffLine]
29+
) {
30+
self.id = id
31+
self.oldStart = oldStart
32+
self.oldCount = oldCount
33+
self.newStart = newStart
34+
self.newCount = newCount
35+
self.header = header
36+
self.lines = lines
37+
}
2038
}

Sources/gitdiff/Models/DiffLine.swift

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,29 @@
99
import Foundation
1010

1111
/// Represents a single line in a diff.
12-
struct DiffLine: Identifiable {
13-
let id = UUID()
14-
let type: LineType
15-
let content: String
16-
let oldLineNumber: Int?
17-
let newLineNumber: Int?
18-
12+
public struct DiffLine: Identifiable, Sendable {
13+
public let id: UUID
14+
public let type: LineType
15+
public let content: String
16+
public let oldLineNumber: Int?
17+
public let newLineNumber: Int?
18+
19+
public init(
20+
id: UUID = UUID(),
21+
type: LineType,
22+
content: String,
23+
oldLineNumber: Int?,
24+
newLineNumber: Int?
25+
) {
26+
self.id = id
27+
self.type = type
28+
self.content = content
29+
self.oldLineNumber = oldLineNumber
30+
self.newLineNumber = newLineNumber
31+
}
32+
1933
/// Type of diff line.
20-
enum LineType {
34+
public enum LineType: Sendable {
2135
case added /// Line was added (+)
2236
case removed /// Line was removed (-)
2337
case context /// Unchanged context line

Sources/gitdiff/Views/DiffRenderer.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import SwiftUI
2020
/// ```
2121
public struct DiffRenderer: View {
2222
let diffText: String
23-
23+
2424
@Environment(\.diffConfiguration) private var configuration
25-
25+
@Environment(\.diffParser) private var parser
26+
2627
@State private var parsedFiles: [DiffFile]? = nil
27-
28+
2829
public init(diffText: String) {
2930
self.diffText = diffText
3031
}
@@ -67,7 +68,7 @@ public struct DiffRenderer: View {
6768
}
6869
}
6970
.task(id: diffText) {
70-
self.parsedFiles = try? await DiffParser.parse(diffText)
71+
self.parsedFiles = try? await parser.parse(diffText)
7172
}
7273
}
7374
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Testing
2+
3+
@testable import gitdiff
4+
5+
/// Contract tests for the `DiffParsing` extension point: custom parsers can
6+
/// construct the library's domain model directly without relying on the
7+
/// built-in unified-diff parser.
8+
struct DiffParsingTests {
9+
10+
// MARK: - UnifiedDiffParser
11+
12+
@Test
13+
func unifiedDiffParserMatchesLegacyStaticParser() async throws {
14+
let diff = """
15+
diff --git a/foo.txt b/foo.txt
16+
index 1111111..2222222 100644
17+
--- a/foo.txt
18+
+++ b/foo.txt
19+
@@ -1,2 +1,3 @@
20+
line1
21+
-line2
22+
+line2 changed
23+
+line3
24+
"""
25+
26+
let legacy = try await DiffParser.parse(diff)
27+
let viaProtocol = try await UnifiedDiffParser().parse(diff)
28+
29+
#expect(legacy.count == viaProtocol.count)
30+
for (a, b) in zip(legacy, viaProtocol) {
31+
#expect(a.oldPath == b.oldPath)
32+
#expect(a.newPath == b.newPath)
33+
#expect(a.hunks.count == b.hunks.count)
34+
for (h1, h2) in zip(a.hunks, b.hunks) {
35+
#expect(h1.lines.count == h2.lines.count)
36+
for (l1, l2) in zip(h1.lines, h2.lines) {
37+
#expect(l1.type == l2.type)
38+
#expect(l1.content == l2.content)
39+
}
40+
}
41+
}
42+
}
43+
44+
// MARK: - Custom parser path
45+
46+
@Test
47+
func customParserCanBuildDiffFilesDirectly() async throws {
48+
/// A throwaway parser that ignores its input and produces a single
49+
/// hand-built `DiffFile`. This exercises the public initialisers on
50+
/// `DiffFile` / `DiffHunk` / `DiffLine` and the protocol contract.
51+
struct FixtureParser: DiffParsing {
52+
let filePath: String
53+
func parse(_ diffText: String) async throws -> [DiffFile] {
54+
[
55+
DiffFile(
56+
oldPath: filePath,
57+
newPath: filePath,
58+
hunks: [
59+
DiffHunk(
60+
oldStart: 1,
61+
oldCount: 1,
62+
newStart: 1,
63+
newCount: 1,
64+
header: "fixture",
65+
lines: [
66+
DiffLine(type: .removed, content: "old", oldLineNumber: 1, newLineNumber: nil),
67+
DiffLine(type: .added, content: "new", oldLineNumber: nil, newLineNumber: 1),
68+
]
69+
)
70+
]
71+
)
72+
]
73+
}
74+
}
75+
76+
let files = try await FixtureParser(filePath: "x.swift").parse("anything")
77+
#expect(files.count == 1)
78+
#expect(files[0].oldPath == "x.swift")
79+
#expect(files[0].hunks[0].lines[0].type == .removed)
80+
#expect(files[0].hunks[0].lines[1].type == .added)
81+
}
82+
83+
@Test
84+
func defaultEnvironmentParserIsUnifiedDiffParser() {
85+
/// Confirms the environment default that `DiffRenderer` reads.
86+
let key = DiffParserKey.defaultValue
87+
#expect(key is UnifiedDiffParser)
88+
}
89+
90+
@Test
91+
func parserCanReturnEmptyArrayToTriggerNoContentState() async throws {
92+
struct EmptyParser: DiffParsing {
93+
func parse(_ diffText: String) async throws -> [DiffFile] { [] }
94+
}
95+
let result = try await EmptyParser().parse("ignored")
96+
#expect(result.isEmpty)
97+
}
98+
}

0 commit comments

Comments
 (0)