diff --git a/README.md b/README.md index a55a456..0ee041d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/gitdiff/Core/DiffParsing.swift b/Sources/gitdiff/Core/DiffParsing.swift new file mode 100644 index 0000000..5a97466 --- /dev/null +++ b/Sources/gitdiff/Core/DiffParsing.swift @@ -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) + } +} diff --git a/Sources/gitdiff/DiffEnvironment.swift b/Sources/gitdiff/DiffEnvironment.swift index 64da3b9..925872f 100644 --- a/Sources/gitdiff/DiffEnvironment.swift +++ b/Sources/gitdiff/DiffEnvironment.swift @@ -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 @@ -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 diff --git a/Sources/gitdiff/Models/DiffFile.swift b/Sources/gitdiff/Models/DiffFile.swift index 221be8d..8cf2d4f 100644 --- a/Sources/gitdiff/Models/DiffFile.swift +++ b/Sources/gitdiff/Models/DiffFile.swift @@ -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)" } diff --git a/Sources/gitdiff/Models/DiffHunk.swift b/Sources/gitdiff/Models/DiffHunk.swift index 98d74f9..56e2e53 100644 --- a/Sources/gitdiff/Models/DiffHunk.swift +++ b/Sources/gitdiff/Models/DiffHunk.swift @@ -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 + } } diff --git a/Sources/gitdiff/Models/DiffLine.swift b/Sources/gitdiff/Models/DiffLine.swift index 23216bc..a117203 100644 --- a/Sources/gitdiff/Models/DiffLine.swift +++ b/Sources/gitdiff/Models/DiffLine.swift @@ -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 diff --git a/Sources/gitdiff/Views/DiffRenderer.swift b/Sources/gitdiff/Views/DiffRenderer.swift index f23be03..f286b18 100644 --- a/Sources/gitdiff/Views/DiffRenderer.swift +++ b/Sources/gitdiff/Views/DiffRenderer.swift @@ -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 } @@ -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) } } } diff --git a/Tests/gitdiffTests/DiffParsingTests.swift b/Tests/gitdiffTests/DiffParsingTests.swift new file mode 100644 index 0000000..b1420f8 --- /dev/null +++ b/Tests/gitdiffTests/DiffParsingTests.swift @@ -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) + } +}