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..142a3e3 --- /dev/null +++ b/Sources/TextDiff/DiffSegmentIndexer.swift @@ -0,0 +1,79 @@ +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) + // 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)" + ) + 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 0000000..63d3f69 Binary files /dev/null and b/Tests/TextDiffTests/__Snapshots__/TextDiffSnapshotTests/precomputed_result_rendering.1.png differ