From c51ffb0481e966ca063e1c78d2b1512b0142538c Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Thu, 26 Mar 2026 00:48:17 +0100 Subject: [PATCH 1/3] Decoupling TextDiff engine --- README.md | 62 ++++++++ .../AppKit/DiffRevertActionResolver.swift | 83 +---------- .../AppKit/DiffTextViewRepresentable.swift | 38 +++-- .../AppKit/NSTextDiffContentSource.swift | 13 ++ Sources/TextDiff/AppKit/NSTextDiffView.swift | 65 ++++++++- Sources/TextDiff/DiffSegmentIndexer.swift | 77 ++++++++++ Sources/TextDiff/TextDiffEngine.swift | 96 +++++++++++++ Sources/TextDiff/TextDiffResult.swift | 101 ++++++++++++++ Sources/TextDiff/TextDiffView.swift | 38 +++++ .../DiffRevertActionResolverTests.swift | 2 +- Tests/TextDiffTests/NSTextDiffViewTests.swift | 56 ++++++++ Tests/TextDiffTests/SnapshotTestSupport.swift | 43 ++++++ Tests/TextDiffTests/TextDiffEngineTests.swift | 132 ++++++++++++++++++ .../TextDiffTests/TextDiffSnapshotTests.swift | 13 ++ .../precomputed_result_rendering.1.png | Bin 0 -> 7015 bytes 15 files changed, 722 insertions(+), 97 deletions(-) create mode 100644 Sources/TextDiff/AppKit/NSTextDiffContentSource.swift create mode 100644 Sources/TextDiff/DiffSegmentIndexer.swift create mode 100644 Sources/TextDiff/TextDiffResult.swift create mode 100644 Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png diff --git a/README.md b/README.md index ce44696..b6fce09 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,66 @@ TextDiffView( - `.token` (default): token-level diff behavior. - `.character`: refines adjacent word replacements by character so shared parts remain unchanged text (for example `Add` -> `Added` shows unchanged `Add` and inserted `ed`). +## Engine-Only Results + +You can compute a reusable diff result without rendering a view: + +```swift +import TextDiff + +let result = TextDiffEngine.result( + original: "Track old values in storage.", + updated: "Track new values in storage.", + mode: .token +) + +for change in result.changes { + print(change.kind, change.text) +} + +print(result.summary.insertedCharacters) +print(result.summary.deletedCharacters) +``` + +`TextDiffResult.changes` preserves the computed diff order for the selected mode and uses UTF-16 offsets/lengths so it can be stored and replayed consistently later. Summaries are derived from those mode-specific change records. + +## Precomputed Rendering + +If you already computed a diff result for storage or analytics, you can render it later without recomputing: + +```swift +import SwiftUI +import TextDiff + +let result = TextDiffEngine.result( + original: "Track old values in storage.", + updated: "Track new values in storage.", + mode: .token +) + +struct StoredDiffView: View { + var body: some View { + TextDiffView(result: result) + .padding() + } +} +``` + +AppKit has the same precomputed rendering path: + +```swift +import AppKit +import TextDiff + +let result = TextDiffEngine.result( + original: "Track old values in storage.", + updated: "Track new values in storage.", + mode: .token +) + +let diffView = NSTextDiffView(result: result) +``` + ## Custom Styling ```swift @@ -123,8 +183,10 @@ Change-specific colors and text treatment live under `additionsStyle` and `remov - Matching is exact (case-sensitive and punctuation-sensitive). - Replacements are rendered as adjacent delete then insert segments. - Character mode refines adjacent word replacements only; punctuation and whitespace keep token-level behavior. +- `TextDiffResult.changes` and `TextDiffResult.summary` are mode-specific outputs; `.token` and `.character` results are not normalized to each other. - Whitespace changes preserve the `updated` layout and stay visually neutral (no chips). - Rendering is display-only (not selectable) to keep chip geometry deterministic. +- Result-driven rendering (`TextDiffView(result:)`, `NSTextDiffView(result:)`) is display-only and does not enable revert actions. - `interChipSpacing` controls spacing between adjacent changed lexical chips (words or punctuation). - `lineSpacing` controls vertical spacing between wrapped lines. - Chip horizontal padding is preserved with a minimum effective floor of 3pt per side. diff --git a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift index ed21b55..2b538d4 100644 --- a/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift +++ b/Sources/TextDiff/AppKit/DiffRevertActionResolver.swift @@ -1,15 +1,6 @@ import CoreGraphics import Foundation -struct IndexedSegment { - let segmentIndex: Int - let segment: DiffSegment - let originalCursor: Int - let updatedCursor: Int - let originalRange: NSRange - let updatedRange: NSRange -} - enum DiffRevertCandidateKind: Equatable { case singleInsertion case singleDeletion @@ -35,67 +26,6 @@ struct DiffRevertInteractionContext { } enum DiffRevertActionResolver { - static func indexedSegments( - from segments: [DiffSegment], - original: String, - updated: String - ) -> [IndexedSegment] { - var output: [IndexedSegment] = [] - output.reserveCapacity(segments.count) - - let originalNSString = original as NSString - let updatedNSString = updated as NSString - var originalCursor = 0 - var updatedCursor = 0 - - for (index, segment) in segments.enumerated() { - let textLength = segment.text.utf16.count - let originalRange: NSRange - let updatedRange: NSRange - - switch segment.kind { - case .equal: - originalRange = NSRange(location: originalCursor, length: textLength) - updatedRange = NSRange(location: updatedCursor, length: textLength) - let originalMatches = textMatches(segment.text, source: originalNSString, at: originalCursor) - let updatedMatches = textMatches(segment.text, source: updatedNSString, at: updatedCursor) - #if !TESTING - assert( - originalMatches, - "Equal segment text mismatch in original at \(originalCursor) for segment \(index): \(segment.text)" - ) - assert( - updatedMatches, - "Equal segment text mismatch in updated at \(updatedCursor) for segment \(index): \(segment.text)" - ) - #endif - originalCursor += textLength - updatedCursor += textLength - case .delete: - originalRange = NSRange(location: originalCursor, length: textLength) - updatedRange = NSRange(location: updatedCursor, length: 0) - originalCursor += textLength - case .insert: - originalRange = NSRange(location: originalCursor, length: 0) - updatedRange = NSRange(location: updatedCursor, length: textLength) - updatedCursor += textLength - } - - output.append( - IndexedSegment( - segmentIndex: index, - segment: segment, - originalCursor: originalRange.location, - updatedCursor: updatedRange.location, - originalRange: originalRange, - updatedRange: updatedRange - ) - ) - } - - return output - } - static func candidates( from segments: [DiffSegment], mode: TextDiffComparisonMode @@ -121,7 +51,7 @@ enum DiffRevertActionResolver { return [] } - let indexed = indexedSegments(from: segments, original: original, updated: updated) + let indexed = DiffSegmentIndexer.indexedSegments(from: segments, original: original, updated: updated) guard !indexed.isEmpty else { return [] } @@ -181,7 +111,7 @@ enum DiffRevertActionResolver { kind: .singleDeletion, tokenKind: current.segment.tokenKind, segmentIndices: [current.segmentIndex], - updatedRange: NSRange(location: current.updatedCursor, length: 0), + updatedRange: NSRange(location: current.updatedRange.location, length: 0), replacementText: current.segment.text, originalTextFragment: current.segment.text, updatedTextFragment: nil @@ -331,15 +261,6 @@ enum DiffRevertActionResolver { return false } - - private static func textMatches(_ text: String, source: NSString, at location: Int) -> Bool { - let length = text.utf16.count - guard location >= 0, location + length <= source.length else { - return false - } - return source.substring(with: NSRange(location: location, length: length)) == text - } - private static func adjustedStandaloneWordDeletionReplacement( _ replacement: String, insertionLocation: Int, diff --git a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift index f195e42..1efd189 100644 --- a/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift +++ b/Sources/TextDiff/AppKit/DiffTextViewRepresentable.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI struct DiffTextViewRepresentable: NSViewRepresentable { + let result: TextDiffResult? let original: String let updated: String let updatedBinding: Binding? @@ -16,12 +17,17 @@ struct DiffTextViewRepresentable: NSViewRepresentable { } func makeNSView(context: Context) -> NSTextDiffView { - let view = NSTextDiffView( - original: original, - updated: updated, - style: style, - mode: mode - ) + let view: NSTextDiffView + if let result { + view = NSTextDiffView(result: result, style: style) + } else { + view = NSTextDiffView( + original: original, + updated: updated, + style: style, + mode: mode + ) + } view.setContentCompressionResistancePriority(.required, for: .vertical) view.setContentHuggingPriority(.required, for: .vertical) context.coordinator.update( @@ -29,7 +35,7 @@ struct DiffTextViewRepresentable: NSViewRepresentable { onRevertAction: onRevertAction ) view.showsInvisibleCharacters = showsInvisibleCharacters - view.isRevertActionsEnabled = isRevertActionsEnabled + view.isRevertActionsEnabled = result == nil ? isRevertActionsEnabled : false view.onRevertAction = { [coordinator = context.coordinator] action in coordinator.handle(action) } @@ -45,13 +51,17 @@ struct DiffTextViewRepresentable: NSViewRepresentable { coordinator.handle(action) } view.showsInvisibleCharacters = showsInvisibleCharacters - view.isRevertActionsEnabled = isRevertActionsEnabled - view.setContent( - original: original, - updated: updated, - style: style, - mode: mode - ) + view.isRevertActionsEnabled = result == nil ? isRevertActionsEnabled : false + if let result { + view.setContent(result: result, style: style) + } else { + view.setContent( + original: original, + updated: updated, + style: style, + mode: mode + ) + } } final class Coordinator { diff --git a/Sources/TextDiff/AppKit/NSTextDiffContentSource.swift b/Sources/TextDiff/AppKit/NSTextDiffContentSource.swift new file mode 100644 index 0000000..21cabb3 --- /dev/null +++ b/Sources/TextDiff/AppKit/NSTextDiffContentSource.swift @@ -0,0 +1,13 @@ +import Foundation + +enum NSTextDiffContentSource { + case text + case result(TextDiffResult) + + var isResultDriven: Bool { + if case .result = self { + return true + } + return false + } +} diff --git a/Sources/TextDiff/AppKit/NSTextDiffView.swift b/Sources/TextDiff/AppKit/NSTextDiffView.swift index a6dc468..0802051 100644 --- a/Sources/TextDiff/AppKit/NSTextDiffView.swift +++ b/Sources/TextDiff/AppKit/NSTextDiffView.swift @@ -12,6 +12,7 @@ public final class NSTextDiffView: NSView { guard !isBatchUpdating else { return } + contentSource = .text _ = updateSegmentsIfNeeded() } } @@ -23,6 +24,7 @@ public final class NSTextDiffView: NSView { guard !isBatchUpdating else { return } + contentSource = .text _ = updateSegmentsIfNeeded() } } @@ -46,6 +48,7 @@ public final class NSTextDiffView: NSView { guard !isBatchUpdating else { return } + contentSource = .text _ = updateSegmentsIfNeeded() } } @@ -56,6 +59,9 @@ public final class NSTextDiffView: NSView { guard oldValue != isRevertActionsEnabled else { return } + guard !contentSource.isResultDriven else { + return + } invalidateCachedLayout() } } @@ -75,6 +81,7 @@ public final class NSTextDiffView: NSView { private var segments: [DiffSegment] private let diffProvider: DiffProvider + private var contentSource: NSTextDiffContentSource private var lastOriginal: String private var lastUpdated: String @@ -139,6 +146,7 @@ public final class NSTextDiffView: NSView { self.diffProvider = { original, updated, mode in TextDiffEngine.diff(original: original, updated: updated, mode: mode) } + self.contentSource = .text self.lastOriginal = original self.lastUpdated = updated self.lastModeKey = Self.modeKey(for: mode) @@ -146,6 +154,28 @@ public final class NSTextDiffView: NSView { super.init(frame: .zero) } + /// Creates a text diff view backed by a precomputed result. + /// + /// Result-driven rendering is display-only. Revert actions are unavailable in this mode. + public init( + result: TextDiffResult, + style: TextDiffStyle = .default + ) { + self.original = result.original + self.updated = result.updated + self.style = style + self.mode = result.mode + self.diffProvider = { original, updated, mode in + TextDiffEngine.diff(original: original, updated: updated, mode: mode) + } + self.contentSource = .result(result) + self.lastOriginal = result.original + self.lastUpdated = result.updated + self.lastModeKey = Self.modeKey(for: result.mode) + self.segments = result.segments + super.init(frame: .zero) + } + #if TESTING init( original: String, @@ -159,6 +189,7 @@ public final class NSTextDiffView: NSView { self.style = style self.mode = mode self.diffProvider = diffProvider + self.contentSource = .text self.lastOriginal = original self.lastUpdated = updated self.lastModeKey = Self.modeKey(for: mode) @@ -254,6 +285,7 @@ public final class NSTextDiffView: NSView { let needsStyleInvalidation = pendingStyleInvalidation pendingStyleInvalidation = false + contentSource = .text let didRecompute = updateSegmentsIfNeeded() if needsStyleInvalidation, !didRecompute { invalidateCachedLayout() @@ -266,6 +298,23 @@ public final class NSTextDiffView: NSView { self.updated = updated } + /// Atomically updates the view with a precomputed diff result. + /// + /// Result-driven rendering is display-only. Revert actions remain unavailable in this mode. + public func setContent( + result: TextDiffResult, + style: TextDiffStyle + ) { + isBatchUpdating = true + defer { + isBatchUpdating = false + pendingStyleInvalidation = false + } + + self.style = style + apply(result: result) + } + @discardableResult private func updateSegmentsIfNeeded() -> Bool { let newModeKey = Self.modeKey(for: mode) @@ -277,11 +326,25 @@ public final class NSTextDiffView: NSView { lastUpdated = updated lastModeKey = newModeKey segments = diffProvider(original, updated, mode) + contentSource = .text segmentGeneration += 1 invalidateCachedLayout() return true } + private func apply(result: TextDiffResult) { + contentSource = .result(result) + original = result.original + updated = result.updated + mode = result.mode + lastOriginal = result.original + lastUpdated = result.updated + lastModeKey = Self.modeKey(for: result.mode) + segments = result.segments + segmentGeneration += 1 + invalidateCachedLayout() + } + private func layoutForCurrentWidth() -> DiffLayout { let width = max(bounds.width, 1) if let cachedLayout, abs(cachedWidth - width) <= 0.5 { @@ -305,7 +368,7 @@ public final class NSTextDiffView: NSView { } private func interactionContext(for layout: DiffLayout) -> DiffRevertInteractionContext? { - guard isRevertActionsEnabled, mode == .token else { + guard isRevertActionsEnabled, mode == .token, !contentSource.isResultDriven else { return nil } diff --git a/Sources/TextDiff/DiffSegmentIndexer.swift b/Sources/TextDiff/DiffSegmentIndexer.swift new file mode 100644 index 0000000..00676f2 --- /dev/null +++ b/Sources/TextDiff/DiffSegmentIndexer.swift @@ -0,0 +1,77 @@ +import Foundation + +struct IndexedDiffSegment { + let segmentIndex: Int + let segment: DiffSegment + let originalRange: NSRange + let updatedRange: NSRange +} + +enum DiffSegmentIndexer { + static func indexedSegments( + from segments: [DiffSegment], + original: String, + updated: String + ) -> [IndexedDiffSegment] { + var output: [IndexedDiffSegment] = [] + output.reserveCapacity(segments.count) + + let originalNSString = original as NSString + let updatedNSString = updated as NSString + var originalCursor = 0 + var updatedCursor = 0 + + for (index, segment) in segments.enumerated() { + let textLength = segment.text.utf16.count + let originalRange: NSRange + let updatedRange: NSRange + + switch segment.kind { + case .equal: + originalRange = NSRange(location: originalCursor, length: textLength) + updatedRange = NSRange(location: updatedCursor, length: textLength) + let originalMatches = textMatches(segment.text, source: originalNSString, at: originalCursor) + let updatedMatches = textMatches(segment.text, source: updatedNSString, at: updatedCursor) + #if !TESTING + assert( + originalMatches, + "Equal segment text mismatch in original at \(originalCursor) for segment \(index): \(segment.text)" + ) + assert( + updatedMatches, + "Equal segment text mismatch in updated at \(updatedCursor) for segment \(index): \(segment.text)" + ) + #endif + originalCursor += textLength + updatedCursor += textLength + case .delete: + originalRange = NSRange(location: originalCursor, length: textLength) + updatedRange = NSRange(location: updatedCursor, length: 0) + originalCursor += textLength + case .insert: + originalRange = NSRange(location: originalCursor, length: 0) + updatedRange = NSRange(location: updatedCursor, length: textLength) + updatedCursor += textLength + } + + output.append( + IndexedDiffSegment( + segmentIndex: index, + segment: segment, + originalRange: originalRange, + updatedRange: updatedRange + ) + ) + } + + return output + } + + private static func textMatches(_ text: String, source: NSString, at location: Int) -> Bool { + let length = text.utf16.count + guard location >= 0, location + length <= source.length else { + return false + } + return source.substring(with: NSRange(location: location, length: length)) == text + } +} diff --git a/Sources/TextDiff/TextDiffEngine.swift b/Sources/TextDiff/TextDiffEngine.swift index e25ee15..084818c 100644 --- a/Sources/TextDiff/TextDiffEngine.swift +++ b/Sources/TextDiff/TextDiffEngine.swift @@ -14,6 +14,41 @@ public enum TextDiffEngine { original: String, updated: String, mode: TextDiffComparisonMode = .token + ) -> [DiffSegment] { + computeSegments(original: original, updated: updated, mode: mode) + } + + /// Computes a reusable diff result with render-ready segments and change records. + /// + /// - Parameters: + /// - original: The source text before edits. + /// - updated: The source text after edits. + /// - mode: The comparison mode used to produce diff output. + /// - Returns: A reusable result that contains render-ready segments, ordered change records, + /// and lightweight summary statistics. + public static func result( + original: String, + updated: String, + mode: TextDiffComparisonMode = .token + ) -> TextDiffResult { + let segments = computeSegments(original: original, updated: updated, mode: mode) + let changes = makeChanges(original: original, updated: updated, segments: segments) + let summary = makeSummary(changes: changes) + + return TextDiffResult( + original: original, + updated: updated, + mode: mode, + segments: segments, + changes: changes, + summary: summary + ) + } + + private static func computeSegments( + original: String, + updated: String, + mode: TextDiffComparisonMode ) -> [DiffSegment] { let segments = tokenDiffSegments(original: original, updated: updated) switch mode { @@ -24,6 +59,67 @@ public enum TextDiffEngine { } } + private static func makeChanges( + original: String, + updated: String, + segments: [DiffSegment] + ) -> [TextDiffChange] { + let indexed = DiffSegmentIndexer.indexedSegments(from: segments, original: original, updated: updated) + + return indexed.compactMap { indexedSegment in + let segment = indexedSegment.segment + switch segment.kind { + case .equal: + return nil + case .insert: + return TextDiffChange( + kind: .insert, + tokenKind: segment.tokenKind, + text: segment.text, + originalOffset: indexedSegment.originalRange.location, + originalLength: 0, + updatedOffset: indexedSegment.updatedRange.location, + updatedLength: indexedSegment.updatedRange.length + ) + case .delete: + return TextDiffChange( + kind: .delete, + tokenKind: segment.tokenKind, + text: segment.text, + originalOffset: indexedSegment.originalRange.location, + originalLength: indexedSegment.originalRange.length, + updatedOffset: indexedSegment.updatedRange.location, + updatedLength: 0 + ) + } + } + } + + private static func makeSummary(changes: [TextDiffChange]) -> TextDiffSummary { + var insertionCount = 0 + var deletionCount = 0 + var insertedCharacters = 0 + var deletedCharacters = 0 + + for change in changes { + switch change.kind { + case .insert: + insertionCount += 1 + insertedCharacters += change.text.count + case .delete: + deletionCount += 1 + deletedCharacters += change.text.count + } + } + + return TextDiffSummary( + changeRecordInsertions: insertionCount, + changeRecordDeletions: deletionCount, + insertedCharacters: insertedCharacters, + deletedCharacters: deletedCharacters + ) + } + private static func tokenDiffSegments(original: String, updated: String) -> [DiffSegment] { let originalTokens = Tokenizer.tokenize(original) let updatedTokens = Tokenizer.tokenize(updated) diff --git a/Sources/TextDiff/TextDiffResult.swift b/Sources/TextDiff/TextDiffResult.swift new file mode 100644 index 0000000..d063468 --- /dev/null +++ b/Sources/TextDiff/TextDiffResult.swift @@ -0,0 +1,101 @@ +import Foundation + +/// The kind of non-equal change represented in a diff result. +public enum TextDiffChangeKind: Sendable, Equatable { + /// Text inserted into the updated value. + case insert + /// Text removed from the original value. + case delete +} + +/// A persistable change record produced from a computed diff. +public struct TextDiffChange: Sendable, Equatable { + /// The insert/delete kind of this change record. + public let kind: TextDiffChangeKind + /// The lexical category of the changed text. + public let tokenKind: DiffTokenKind + /// The inserted or deleted text for this record. + public let text: String + /// The UTF-16 anchor position in the original text for this change. + public let originalOffset: Int + /// The UTF-16 length in the original text affected by this change. + public let originalLength: Int + /// The UTF-16 anchor position in the updated text for this change. + public let updatedOffset: Int + /// The UTF-16 length in the updated text affected by this change. + public let updatedLength: Int + + public init( + kind: TextDiffChangeKind, + tokenKind: DiffTokenKind, + text: String, + originalOffset: Int, + originalLength: Int, + updatedOffset: Int, + updatedLength: Int + ) { + self.kind = kind + self.tokenKind = tokenKind + self.text = text + self.originalOffset = originalOffset + self.originalLength = originalLength + self.updatedOffset = updatedOffset + self.updatedLength = updatedLength + } +} + +/// Lightweight summary statistics derived from diff change records. +public struct TextDiffSummary: Sendable, Equatable { + /// The number of insert change records. + public let changeRecordInsertions: Int + /// The number of delete change records. + public let changeRecordDeletions: Int + /// The number of visible characters inserted across all change records. + public let insertedCharacters: Int + /// The number of visible characters deleted across all change records. + public let deletedCharacters: Int + + public init( + changeRecordInsertions: Int, + changeRecordDeletions: Int, + insertedCharacters: Int, + deletedCharacters: Int + ) { + self.changeRecordInsertions = changeRecordInsertions + self.changeRecordDeletions = changeRecordDeletions + self.insertedCharacters = insertedCharacters + self.deletedCharacters = deletedCharacters + } +} + +/// A reusable diff payload that can be persisted or rendered later. +public struct TextDiffResult: Sendable, Equatable { + /// The source text before edits. + public let original: String + /// The source text after edits. + public let updated: String + /// The comparison mode used to compute this result. + public let mode: TextDiffComparisonMode + /// Render-ready ordered segments for this diff. + public let segments: [DiffSegment] + /// Ordered insert/delete records after segment refinement and merging. + public let changes: [TextDiffChange] + /// Summary values derived from `changes`. + public let summary: TextDiffSummary + + public init( + original: String, + updated: String, + mode: TextDiffComparisonMode, + segments: [DiffSegment], + changes: [TextDiffChange], + summary: TextDiffSummary + ) { + self.original = original + self.updated = updated + self.mode = mode + self.segments = segments + self.changes = changes + self.summary = summary + } +} diff --git a/Sources/TextDiff/TextDiffView.swift b/Sources/TextDiff/TextDiffView.swift index 0df08d2..11ec750 100644 --- a/Sources/TextDiff/TextDiffView.swift +++ b/Sources/TextDiff/TextDiffView.swift @@ -3,6 +3,7 @@ import SwiftUI /// A SwiftUI view that renders a merged visual diff between two strings. public struct TextDiffView: View { + private let result: TextDiffResult? private let original: String private let updatedValue: String private let updatedBinding: Binding? @@ -27,6 +28,7 @@ public struct TextDiffView: View { mode: TextDiffComparisonMode = .token, showsInvisibleCharacters: Bool = false ) { + self.result = nil self.original = original self.updatedValue = updated self.updatedBinding = nil @@ -56,6 +58,7 @@ public struct TextDiffView: View { isRevertActionsEnabled: Bool = true, onRevertAction: ((TextDiffRevertAction) -> Void)? = nil ) { + self.result = nil self.original = original self.updatedValue = updated.wrappedValue self.updatedBinding = updated @@ -66,10 +69,33 @@ public struct TextDiffView: View { self.onRevertAction = onRevertAction } + /// Creates a display-only diff view backed by a precomputed result. + /// + /// - Parameters: + /// - result: A precomputed diff result to render. + /// - style: Visual style used to render additions, deletions, and unchanged text. + /// - showsInvisibleCharacters: Debug-only overlay that draws whitespace/newline symbols in red. + public init( + result: TextDiffResult, + style: TextDiffStyle = .default, + showsInvisibleCharacters: Bool = false + ) { + self.result = result + self.original = result.original + self.updatedValue = result.updated + self.updatedBinding = nil + self.mode = result.mode + self.style = style + self.showsInvisibleCharacters = showsInvisibleCharacters + self.isRevertActionsEnabled = false + self.onRevertAction = nil + } + /// The view body that renders the current diff content. public var body: some View { let updated = updatedBinding?.wrappedValue ?? updatedValue DiffTextViewRepresentable( + result: result, original: original, updated: updated, updatedBinding: updatedBinding, @@ -176,6 +202,18 @@ public struct TextDiffView: View { .frame(width: 320) } +#Preview("Precomputed Result") { + TextDiffView( + result: TextDiffEngine.result( + original: "Track deleted text in storage.", + updated: "Track inserted text in storage.", + mode: .token + ) + ) + .padding() + .frame(width: 360) +} + #Preview("Revert Binding") { RevertBindingPreview() } diff --git a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift index 0fe5155..dd607c6 100644 --- a/Tests/TextDiffTests/DiffRevertActionResolverTests.swift +++ b/Tests/TextDiffTests/DiffRevertActionResolverTests.swift @@ -33,7 +33,7 @@ func indexedSegmentsAdvancePastEqualSegmentsEvenWhenTextValidationFails() { DiffSegment(kind: .insert, tokenKind: .word, text: "Y") ] - let indexed = DiffRevertActionResolver.indexedSegments( + let indexed = DiffSegmentIndexer.indexedSegments( from: segments, original: "zzzX", updated: "zzzY" diff --git a/Tests/TextDiffTests/NSTextDiffViewTests.swift b/Tests/TextDiffTests/NSTextDiffViewTests.swift index f73e1e2..9edbb9a 100644 --- a/Tests/TextDiffTests/NSTextDiffViewTests.swift +++ b/Tests/TextDiffTests/NSTextDiffViewTests.swift @@ -162,6 +162,62 @@ func nsTextDiffViewSetContentStyleOnlyDoesNotRecomputeDiff() { #expect(callCount == 1) } +@Test +@MainActor +func nsTextDiffViewResultContentSynchronizesPublicPropertiesWithoutRecompute() { + var callCount = 0 + let view = NSTextDiffView( + original: "seed-old", + updated: "seed-new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + let result = TextDiffEngine.result(original: "old value", updated: "new value", mode: .character) + view.setContent(result: result, style: .default) + + #expect(callCount == 1) + #expect(view.original == result.original) + #expect(view.updated == result.updated) + #expect(view.mode == result.mode) +} + +@Test +@MainActor +func nsTextDiffViewResultDrivenModeDisablesRevertActions() { + let result = TextDiffEngine.result(original: "old", updated: "new", mode: .token) + let view = NSTextDiffView(result: result) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.isRevertActionsEnabled = true + + #expect(view._testingSetHoveredFirstRevertAction() == false) + #expect(view._testingTriggerHoveredRevertAction() == false) +} + +@Test +@MainActor +func nsTextDiffViewPropertyMutationSwitchesBackToTextDrivenMode() { + var callCount = 0 + let view = NSTextDiffView( + original: "seed-old", + updated: "seed-new", + mode: .token + ) { _, _, _ in + callCount += 1 + return [DiffSegment(kind: .equal, tokenKind: .word, text: "\(callCount)")] + } + + let result = TextDiffEngine.result(original: "old value", updated: "new value", mode: .token) + view.setContent(result: result, style: .default) + #expect(callCount == 1) + + view.updated = "newer value" + + #expect(callCount == 2) +} + @Test @MainActor func nsTextDiffViewRevertDisabledDoesNotEmitAction() { diff --git a/Tests/TextDiffTests/SnapshotTestSupport.swift b/Tests/TextDiffTests/SnapshotTestSupport.swift index a1157a0..6633298 100644 --- a/Tests/TextDiffTests/SnapshotTestSupport.swift +++ b/Tests/TextDiffTests/SnapshotTestSupport.swift @@ -69,6 +69,49 @@ func assertTextDiffSnapshot( } } +@MainActor +func assertTextDiffSnapshot( + result: TextDiffResult, + style: TextDiffStyle = .default, + size: CGSize, + named name: String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + testName: String = #function, + line: UInt = #line, + column: UInt = #column +) { + configureSnapshotArtifactsDirectory(filePath: filePath) + let snapshotStyle = stableSnapshotStyle(from: style) + + let rootView = TextDiffView(result: result, style: snapshotStyle) + .frame(width: size.width, height: size.height, alignment: .topLeading) + .background(Color.white) + + let hostingView = NSHostingView(rootView: rootView) + hostingView.frame = CGRect(origin: .zero, size: size) + hostingView.appearance = NSAppearance(named: .aqua) + hostingView.layoutSubtreeIfNeeded() + + let snapshotImage = renderSnapshotImage1x(view: hostingView, size: size) + + withSnapshotTesting(diffTool: .ksdiff) { + assertSnapshot( + of: snapshotImage, + as: .image( + precision: snapshotPrecision, + perceptualPrecision: snapshotPerceptualPrecision + ), + named: name, + fileID: fileID, + file: filePath, + testName: testName, + line: line, + column: column + ) + } +} + @MainActor func assertNSTextDiffSnapshot( original: String, diff --git a/Tests/TextDiffTests/TextDiffEngineTests.swift b/Tests/TextDiffTests/TextDiffEngineTests.swift index ef56e11..08be88e 100644 --- a/Tests/TextDiffTests/TextDiffEngineTests.swift +++ b/Tests/TextDiffTests/TextDiffEngineTests.swift @@ -182,6 +182,138 @@ func characterModeIsDeterministicForRepeatedCharacterTieCases() { #expect(first == second) } +@Test +func resultSegmentsMatchDiffOutputInTokenMode() { + let result = TextDiffEngine.result(original: "old value", updated: "new value", mode: .token) + let segments = TextDiffEngine.diff(original: "old value", updated: "new value", mode: .token) + + #expect(result.segments == segments) +} + +@Test +func resultSegmentsMatchDiffOutputInCharacterMode() { + let result = TextDiffEngine.result(original: "Add", updated: "Added", mode: .character) + let segments = TextDiffEngine.diff(original: "Add", updated: "Added", mode: .character) + + #expect(result.segments == segments) +} + +@Test +func resultProducesOrderedChangeRecordsForReplacement() { + let result = TextDiffEngine.result(original: "old value", updated: "new value", mode: .token) + + #expect(result.changes == [ + TextDiffChange( + kind: .delete, + tokenKind: .word, + text: "old", + originalOffset: 0, + originalLength: 3, + updatedOffset: 0, + updatedLength: 0 + ), + TextDiffChange( + kind: .insert, + tokenKind: .word, + text: "new", + originalOffset: 3, + originalLength: 0, + updatedOffset: 0, + updatedLength: 3 + ) + ]) +} + +@Test +func resultSummaryCountsChangeRecordsAndCharacters() { + let result = TextDiffEngine.result(original: "old value", updated: "new value", mode: .token) + + #expect(result.summary == TextDiffSummary( + changeRecordInsertions: 1, + changeRecordDeletions: 1, + insertedCharacters: 3, + deletedCharacters: 3 + )) +} + +@Test +func resultSummaryIsModeSpecific() { + let token = TextDiffEngine.result(original: "Add", updated: "Added", mode: .token) + let character = TextDiffEngine.result(original: "Add", updated: "Added", mode: .character) + + #expect(token.summary == TextDiffSummary( + changeRecordInsertions: 1, + changeRecordDeletions: 1, + insertedCharacters: 5, + deletedCharacters: 3 + )) + #expect(character.summary == TextDiffSummary( + changeRecordInsertions: 1, + changeRecordDeletions: 0, + insertedCharacters: 2, + deletedCharacters: 0 + )) +} + +@Test +func fullInsertionProducesAnchoredInsertRecord() { + let result = TextDiffEngine.result(original: "", updated: "Hello", mode: .token) + + #expect(result.changes == [ + TextDiffChange( + kind: .insert, + tokenKind: .word, + text: "Hello", + originalOffset: 0, + originalLength: 0, + updatedOffset: 0, + updatedLength: 5 + ) + ]) +} + +@Test +func fullDeletionProducesAnchoredDeleteRecord() { + let result = TextDiffEngine.result(original: "Hello", updated: "", mode: .token) + + #expect(result.changes == [ + TextDiffChange( + kind: .delete, + tokenKind: .word, + text: "Hello", + originalOffset: 0, + originalLength: 5, + updatedOffset: 0, + updatedLength: 0 + ) + ]) +} + +@Test +func whitespaceOnlyLayoutChangesProduceNoChangeRecords() { + let result = TextDiffEngine.result(original: "Hello world", updated: "Hello world\n", mode: .token) + + #expect(result.changes.isEmpty) + #expect(result.summary == TextDiffSummary( + changeRecordInsertions: 0, + changeRecordDeletions: 0, + insertedCharacters: 0, + deletedCharacters: 0 + )) +} + +@Test +func insertOffsetsUseUtf16AnchorsForEmoji() throws { + let result = TextDiffEngine.result(original: "a", updated: "a🌍", mode: .token) + let change = try #require(result.changes.first) + + #expect(change.kind == .insert) + #expect(change.originalOffset == 1) + #expect(change.originalLength == 0) + #expect(change.updatedOffset == 1) + #expect(change.updatedLength == 2) +} + @Test func defaultStyleInterChipSpacingMatchesCurrentDefault() { #expect(TextDiffStyle.default.interChipSpacing == 0) diff --git a/Tests/TextDiffTests/TextDiffSnapshotTests.swift b/Tests/TextDiffTests/TextDiffSnapshotTests.swift index 0651547..0d010c7 100644 --- a/Tests/TextDiffTests/TextDiffSnapshotTests.swift +++ b/Tests/TextDiffTests/TextDiffSnapshotTests.swift @@ -81,6 +81,19 @@ final class TextDiffSnapshotTests: XCTestCase { ) } + @MainActor + func testPrecomputedResultRendering() { + assertTextDiffSnapshot( + result: TextDiffEngine.result( + original: "Apply old value in this sentence.", + updated: "Apply new value in this sentence.", + mode: .token + ), + size: CGSize(width: 500, height: 120), + testName: "precomputed_result_rendering()" + ) + } + private let sampleOriginalSentence = "A quick brown fox jumps over a lazy dog." private let sampleUpdatedSentence = "A quick fox hops over the lazy dog!" } diff --git a/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png new file mode 100644 index 0000000000000000000000000000000000000000..63d3f69ca67d51e27831b8278287c337c1ec0e21 GIT binary patch literal 7015 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 Date: Thu, 26 Mar 2026 01:05:53 +0100 Subject: [PATCH 2/3] removing !TESTING --- Sources/TextDiff/DiffSegmentIndexer.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/TextDiff/DiffSegmentIndexer.swift b/Sources/TextDiff/DiffSegmentIndexer.swift index 00676f2..88908a6 100644 --- a/Sources/TextDiff/DiffSegmentIndexer.swift +++ b/Sources/TextDiff/DiffSegmentIndexer.swift @@ -32,7 +32,6 @@ enum DiffSegmentIndexer { updatedRange = NSRange(location: updatedCursor, length: textLength) let originalMatches = textMatches(segment.text, source: originalNSString, at: originalCursor) let updatedMatches = textMatches(segment.text, source: updatedNSString, at: updatedCursor) - #if !TESTING assert( originalMatches, "Equal segment text mismatch in original at \(originalCursor) for segment \(index): \(segment.text)" @@ -41,7 +40,6 @@ enum DiffSegmentIndexer { updatedMatches, "Equal segment text mismatch in updated at \(updatedCursor) for segment \(index): \(segment.text)" ) - #endif originalCursor += textLength updatedCursor += textLength case .delete: From 0ae38e4128658184273e9053fd03237c39facc0e Mon Sep 17 00:00:00 2001 From: Ivan Sapozhnik Date: Thu, 26 Mar 2026 01:14:21 +0100 Subject: [PATCH 3/3] revert changes and online doc --- Sources/TextDiff/DiffSegmentIndexer.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/TextDiff/DiffSegmentIndexer.swift b/Sources/TextDiff/DiffSegmentIndexer.swift index 88908a6..142a3e3 100644 --- a/Sources/TextDiff/DiffSegmentIndexer.swift +++ b/Sources/TextDiff/DiffSegmentIndexer.swift @@ -32,6 +32,9 @@ enum DiffSegmentIndexer { updatedRange = NSRange(location: updatedCursor, length: textLength) let originalMatches = textMatches(segment.text, source: originalNSString, at: originalCursor) let updatedMatches = textMatches(segment.text, source: updatedNSString, at: updatedCursor) + // Some tests intentionally feed synthetic `.equal` segments whose text does not + // match the source strings, and the indexer is expected to keep advancing cursors. + #if !TESTING assert( originalMatches, "Equal segment text mismatch in original at \(originalCursor) for segment \(index): \(segment.text)" @@ -40,6 +43,7 @@ enum DiffSegmentIndexer { updatedMatches, "Equal segment text mismatch in updated at \(updatedCursor) for segment \(index): \(segment.text)" ) + #endif originalCursor += textLength updatedCursor += textLength case .delete: