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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,46 @@ struct PullRequestView: View {
- `.diffLineSpacing(_ spacing: LineSpacing)` - Set line spacing
- `.diffWordWrap(_ wrap: Bool)` - Enable word wrapping
- `.diffConfiguration(_ config: DiffConfiguration)` - Apply complete configuration
- `.diffParser(_ parser: any DiffParsing)` - Plug in a custom parser (see below)

## Custom Diff Formats

`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:

```swift
import gitdiff

struct MyAnnotatedDiffParser: DiffParsing {
let filePath: String

func parse(_ diffText: String) async throws -> [DiffFile] {
// Map your custom format → [DiffFile] using the public initializers
// on DiffFile / DiffHunk / DiffLine. The renderer doesn't care how
// you produced the model.
[
DiffFile(
oldPath: filePath,
newPath: filePath,
hunks: [
DiffHunk(
oldStart: 1, oldCount: 1, newStart: 1, newCount: 1,
header: "",
lines: [
DiffLine(type: .removed, content: "old", oldLineNumber: 1, newLineNumber: nil),
DiffLine(type: .added, content: "new", oldLineNumber: nil, newLineNumber: 1),
]
)
]
)
]
}
}

// Inject — default stays `UnifiedDiffParser` if no override.
DiffRenderer(diffText: myRawText)
.diffParser(MyAnnotatedDiffParser(filePath: "foo.swift"))
.diffTheme(.dark)
```

## Example App

Expand Down
39 changes: 39 additions & 0 deletions Sources/gitdiff/Core/DiffParsing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// DiffParsing.swift
// gitdiff
//

import Foundation

/// Strategy for turning a diff text into the renderer's domain model.
///
/// `DiffRenderer` reads the active parser from the environment so callers
/// can plug in formats other than standard unified diff — annotated diffs,
/// pre-tokenised server output, JSON patches, anything that can produce a
/// `[DiffFile]`. Inject via the ``SwiftUI/View/diffParser(_:)`` modifier.
///
/// ```swift
/// DiffRenderer(diffText: rawDiff)
/// .diffParser(MyAnnotatedDiffParser(filePath: "foo.swift"))
/// ```
public protocol DiffParsing: Sendable {
/// Parse the given diff text into the renderer's domain model.
///
/// - Parameter diffText: Raw input in whatever format this parser understands.
/// - Returns: One `DiffFile` per file represented in the input. Returning
/// an empty array causes `DiffRenderer` to show the "no content" state.
/// - Throws: Re-thrown by the renderer's `.task` — usually
/// `CancellationError` if the view disappears mid-parse, but custom
/// parsers may surface format errors here.
func parse(_ diffText: String) async throws -> [DiffFile]
}

/// Default parser — accepts standard unified-diff text (the output of
/// `git diff`, `diff -u`, `Diff.createTwoFilesPatch` from jsdiff, etc.).
public struct UnifiedDiffParser: DiffParsing {
public init() {}

public func parse(_ diffText: String) async throws -> [DiffFile] {
try await DiffParser.parse(diffText)
}
}
21 changes: 21 additions & 0 deletions Sources/gitdiff/DiffEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,23 @@ struct DiffConfigurationKey: EnvironmentKey {
static let defaultValue = DiffConfiguration.default
}

/// Environment key for the active diff parser. Defaults to
/// ``UnifiedDiffParser`` so existing callers see no change.
struct DiffParserKey: EnvironmentKey {
static let defaultValue: any DiffParsing = UnifiedDiffParser()
}

/// Environment extensions for diff configuration
extension EnvironmentValues {
public var diffConfiguration: DiffConfiguration {
get { self[DiffConfigurationKey.self] }
set { self[DiffConfigurationKey.self] = newValue }
}

public var diffParser: any DiffParsing {
get { self[DiffParserKey.self] }
set { self[DiffParserKey.self] = newValue }
}
}

// MARK: - View Modifiers
Expand Down Expand Up @@ -140,6 +151,16 @@ public extension View {
}
}

/// Injects a custom ``DiffParsing`` implementation so the renderer can
/// consume formats other than standard unified diff (annotated diffs,
/// server-side payloads, JSON patches, …). The default parser is
/// ``UnifiedDiffParser``.
///
/// - Parameter parser: Any value conforming to ``DiffParsing``.
func diffParser(_ parser: any DiffParsing) -> some View {
environment(\.diffParser, parser)
}

/// Sets content padding
func diffPadding(_ padding: EdgeInsets) -> some View {
transformEnvironment(\.diffConfiguration) { config in
Expand Down
42 changes: 32 additions & 10 deletions Sources/gitdiff/Models/DiffFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,39 @@

import Foundation

/// Represents a file in a git diff.
struct DiffFile: Identifiable {
let id = UUID()
let oldPath: String
let newPath: String
let hunks: [DiffHunk]
let isBinary: Bool
let isRenamed: Bool

/// Represents a file in a diff.
///
/// The library ships a `UnifiedDiffParser` for standard unified diffs, but
/// any type conforming to ``DiffParsing`` can construct `DiffFile` values
/// directly — that's how consumers plug in custom diff formats (annotated
/// diffs, JSON patches, server-side formats, etc.) without modifying the
/// renderer.
public struct DiffFile: Identifiable, Sendable {
public let id: UUID
public let oldPath: String
public let newPath: String
public let hunks: [DiffHunk]
public let isBinary: Bool
public let isRenamed: Bool

public init(
id: UUID = UUID(),
oldPath: String,
newPath: String,
hunks: [DiffHunk],
isBinary: Bool = false,
isRenamed: Bool = false
) {
self.id = id
self.oldPath = oldPath
self.newPath = newPath
self.hunks = hunks
self.isBinary = isBinary
self.isRenamed = isRenamed
}

/// Formatted name for display, showing rename if applicable.
var displayName: String {
public var displayName: String {
if isRenamed {
return "\(oldPath) → \(newPath)"
}
Expand Down
34 changes: 26 additions & 8 deletions Sources/gitdiff/Models/DiffHunk.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,30 @@
import Foundation

/// Represents a change section (hunk) in a diff file.
struct DiffHunk: Identifiable {
let id = UUID()
let oldStart: Int
let oldCount: Int
let newStart: Int
let newCount: Int
let header: String
let lines: [DiffLine]
public struct DiffHunk: Identifiable, Sendable {
public let id: UUID
public let oldStart: Int
public let oldCount: Int
public let newStart: Int
public let newCount: Int
public let header: String
public let lines: [DiffLine]

public init(
id: UUID = UUID(),
oldStart: Int,
oldCount: Int,
newStart: Int,
newCount: Int,
header: String,
lines: [DiffLine]
) {
self.id = id
self.oldStart = oldStart
self.oldCount = oldCount
self.newStart = newStart
self.newCount = newCount
self.header = header
self.lines = lines
}
}
30 changes: 22 additions & 8 deletions Sources/gitdiff/Models/DiffLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,29 @@
import Foundation

/// Represents a single line in a diff.
struct DiffLine: Identifiable {
let id = UUID()
let type: LineType
let content: String
let oldLineNumber: Int?
let newLineNumber: Int?

public struct DiffLine: Identifiable, Sendable {
public let id: UUID
public let type: LineType
public let content: String
public let oldLineNumber: Int?
public let newLineNumber: Int?

public init(
id: UUID = UUID(),
type: LineType,
content: String,
oldLineNumber: Int?,
newLineNumber: Int?
) {
self.id = id
self.type = type
self.content = content
self.oldLineNumber = oldLineNumber
self.newLineNumber = newLineNumber
}

/// Type of diff line.
enum LineType {
public enum LineType: Sendable {
case added /// Line was added (+)
case removed /// Line was removed (-)
case context /// Unchanged context line
Expand Down
9 changes: 5 additions & 4 deletions Sources/gitdiff/Views/DiffRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import SwiftUI
/// ```
public struct DiffRenderer: View {
let diffText: String

@Environment(\.diffConfiguration) private var configuration

@Environment(\.diffParser) private var parser

@State private var parsedFiles: [DiffFile]? = nil

public init(diffText: String) {
self.diffText = diffText
}
Expand Down Expand Up @@ -67,7 +68,7 @@ public struct DiffRenderer: View {
}
}
.task(id: diffText) {
self.parsedFiles = try? await DiffParser.parse(diffText)
self.parsedFiles = try? await parser.parse(diffText)
}
Comment on lines 70 to 72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Re-parse when parser changes, not only when diffText changes.

Line 70 keys the task only by diffText, so changing .diffParser(...) at runtime won’t trigger a new parse and can show stale output.

Suggested adjustment
-    .task(id: diffText) {
+    .task(id: "\(diffText)|\(String(reflecting: type(of: parser)))") {
       self.parsedFiles = try? await parser.parse(diffText)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.task(id: diffText) {
self.parsedFiles = try? await DiffParser.parse(diffText)
self.parsedFiles = try? await parser.parse(diffText)
}
.task(id: "\(diffText)|\(String(reflecting: type(of: parser)))") {
self.parsedFiles = try? await parser.parse(diffText)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/gitdiff/Views/DiffRenderer.swift` around lines 70 - 72, The Task is
currently keyed only by diffText so updating the .diffParser at runtime won’t
retrigger parsing; change the .task(id:) key to include the parser as well
(e.g., use a tuple of diffText and the parser identity) so that when the parser
instance changes the task restarts and parsedFiles is refreshed; you can either
make the parser Hashable and use id: (diffText, parser) or use id: (diffText,
ObjectIdentifier(parser as AnyObject)) and keep the body calling
self.parsedFiles = try? await parser.parse(diffText).

}
}
Expand Down
98 changes: 98 additions & 0 deletions Tests/gitdiffTests/DiffParsingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Testing

@testable import gitdiff

/// Contract tests for the `DiffParsing` extension point: custom parsers can
/// construct the library's domain model directly without relying on the
/// built-in unified-diff parser.
struct DiffParsingTests {

// MARK: - UnifiedDiffParser

@Test
func unifiedDiffParserMatchesLegacyStaticParser() async throws {
let diff = """
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
"""

let legacy = try await DiffParser.parse(diff)
let viaProtocol = try await UnifiedDiffParser().parse(diff)

#expect(legacy.count == viaProtocol.count)
for (a, b) in zip(legacy, viaProtocol) {
#expect(a.oldPath == b.oldPath)
#expect(a.newPath == b.newPath)
#expect(a.hunks.count == b.hunks.count)
for (h1, h2) in zip(a.hunks, b.hunks) {
#expect(h1.lines.count == h2.lines.count)
for (l1, l2) in zip(h1.lines, h2.lines) {
#expect(l1.type == l2.type)
#expect(l1.content == l2.content)
}
}
}
}

// MARK: - Custom parser path

@Test
func customParserCanBuildDiffFilesDirectly() async throws {
/// A throwaway parser that ignores its input and produces a single
/// hand-built `DiffFile`. This exercises the public initialisers on
/// `DiffFile` / `DiffHunk` / `DiffLine` and the protocol contract.
struct FixtureParser: DiffParsing {
let filePath: String
func parse(_ diffText: String) async throws -> [DiffFile] {
[
DiffFile(
oldPath: filePath,
newPath: filePath,
hunks: [
DiffHunk(
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
header: "fixture",
lines: [
DiffLine(type: .removed, content: "old", oldLineNumber: 1, newLineNumber: nil),
DiffLine(type: .added, content: "new", oldLineNumber: nil, newLineNumber: 1),
]
)
]
)
]
}
}

let files = try await FixtureParser(filePath: "x.swift").parse("anything")
#expect(files.count == 1)
#expect(files[0].oldPath == "x.swift")
#expect(files[0].hunks[0].lines[0].type == .removed)
#expect(files[0].hunks[0].lines[1].type == .added)
}

@Test
func defaultEnvironmentParserIsUnifiedDiffParser() {
/// Confirms the environment default that `DiffRenderer` reads.
let key = DiffParserKey.defaultValue
#expect(key is UnifiedDiffParser)
}

@Test
func parserCanReturnEmptyArrayToTriggerNoContentState() async throws {
struct EmptyParser: DiffParsing {
func parse(_ diffText: String) async throws -> [DiffFile] { [] }
}
let result = try await EmptyParser().parse("ignored")
#expect(result.isEmpty)
}
}
Loading