Skip to content

Commit 83c40a2

Browse files
authored
Sectioned collection diffing. (#3)
1 parent 88e17cc commit 83c40a2

6 files changed

Lines changed: 584 additions & 40 deletions

File tree

FlexibleDiff.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
9A4CCB5D1F95E22100ACF758 /* Nimble.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9A4CCB5B1F95E21700ACF758 /* Nimble.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
2525
9A4CCB5E1F95E22100ACF758 /* Quick.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9A4CCB5C1F95E21700ACF758 /* Quick.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
2626
9A4CCB621F95E38A00ACF758 /* FlexibleDiff.h in Headers */ = {isa = PBXBuildFile; fileRef = 9A4CCB611F95E38A00ACF758 /* FlexibleDiff.h */; settings = {ATTRIBUTES = (Public, ); }; };
27+
9A53A38D1F9BCB5E00B4C411 /* SectionedChangeset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A53A38C1F9BCB5E00B4C411 /* SectionedChangeset.swift */; };
28+
9A94B2B5205427CD00A92D5E /* SectionedChangesetSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A94B2B4205427CD00A92D5E /* SectionedChangesetSpec.swift */; };
29+
9A94B2B72054362000A92D5E /* ReproducibilityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A94B2B62054362000A92D5E /* ReproducibilityTest.swift */; };
2730
9AC7E0911F9B473100461122 /* WordListFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC7E0901F9B473100461122 /* WordListFlowController.swift */; };
2831
9AD5D41A1F95F7FE00E6AE5A /* Changeset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD5D4181F95F7FE00E6AE5A /* Changeset.swift */; };
2932
9AD5D41B1F95F7FE00E6AE5A /* Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD5D4191F95F7FE00E6AE5A /* Snapshot.swift */; };
@@ -121,6 +124,9 @@
121124
9A4CCB5B1F95E21700ACF758 /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; };
122125
9A4CCB5C1F95E21700ACF758 /* Quick.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Quick.framework; sourceTree = BUILT_PRODUCTS_DIR; };
123126
9A4CCB611F95E38A00ACF758 /* FlexibleDiff.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FlexibleDiff.h; sourceTree = "<group>"; };
127+
9A53A38C1F9BCB5E00B4C411 /* SectionedChangeset.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = SectionedChangeset.swift; sourceTree = "<group>"; usesTabs = 1; };
128+
9A94B2B4205427CD00A92D5E /* SectionedChangesetSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionedChangesetSpec.swift; sourceTree = "<group>"; usesTabs = 1; wrapsLines = 0; };
129+
9A94B2B62054362000A92D5E /* ReproducibilityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReproducibilityTest.swift; sourceTree = "<group>"; };
124130
9AC7E0901F9B473100461122 /* WordListFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordListFlowController.swift; sourceTree = "<group>"; };
125131
9AD5D4181F95F7FE00E6AE5A /* Changeset.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Changeset.swift; sourceTree = "<group>"; };
126132
9AD5D4191F95F7FE00E6AE5A /* Snapshot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Snapshot.swift; sourceTree = "<group>"; };
@@ -228,6 +234,7 @@
228234
isa = PBXGroup;
229235
children = (
230236
9AD5D4181F95F7FE00E6AE5A /* Changeset.swift */,
237+
9A53A38C1F9BCB5E00B4C411 /* SectionedChangeset.swift */,
231238
9AD5D4191F95F7FE00E6AE5A /* Snapshot.swift */,
232239
9A4CCB1C1F95DEF000ACF758 /* Info.plist */,
233240
9A4CCB611F95E38A00ACF758 /* FlexibleDiff.h */,
@@ -332,6 +339,8 @@
332339
isa = PBXGroup;
333340
children = (
334341
9AD5D41D1F95F80900E6AE5A /* ChangesetSpec.swift */,
342+
9A94B2B4205427CD00A92D5E /* SectionedChangesetSpec.swift */,
343+
9A94B2B62054362000A92D5E /* ReproducibilityTest.swift */,
335344
9AD5D41C1F95F80900E6AE5A /* Delta+NimbleMatcher.swift */,
336345
9A4CCB501F95E18900ACF758 /* Info.plist */,
337346
);
@@ -519,6 +528,7 @@
519528
files = (
520529
9AD5D41A1F95F7FE00E6AE5A /* Changeset.swift in Sources */,
521530
9AD5D41B1F95F7FE00E6AE5A /* Snapshot.swift in Sources */,
531+
9A53A38D1F9BCB5E00B4C411 /* SectionedChangeset.swift in Sources */,
522532
);
523533
runOnlyForDeploymentPostprocessing = 0;
524534
};
@@ -528,6 +538,8 @@
528538
files = (
529539
9AD5D41E1F95F80900E6AE5A /* Delta+NimbleMatcher.swift in Sources */,
530540
9AD5D41F1F95F80900E6AE5A /* ChangesetSpec.swift in Sources */,
541+
9A94B2B72054362000A92D5E /* ReproducibilityTest.swift in Sources */,
542+
9A94B2B5205427CD00A92D5E /* SectionedChangesetSpec.swift in Sources */,
531543
);
532544
runOnlyForDeploymentPostprocessing = 0;
533545
};

FlexibleDiff/Changeset.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ public struct Changeset {
112112
/// start index and the move offsets.
113113
public var moves = [Move]()
114114

115+
/// Indicate whether there is no change across both versions of the collection.
116+
public var hasNoChanges: Bool {
117+
return inserts.isEmpty && removals.isEmpty && mutations.isEmpty && moves.isEmpty
118+
}
119+
115120
public init() {}
116121

117122
public init(inserts: IndexSet = [], removals: IndexSet = [], mutations: IndexSet = [], moves: [Move] = []) {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import Foundation
2+
3+
/// Represents an atomic batch of changes made to a sectioned collection.
4+
public struct SectionedChangeset {
5+
/// Represents a mutated section for either or both its collection of items
6+
/// and its metadata.
7+
public struct MutatedSection {
8+
/// The offset of the section in the previous version of the sectioned
9+
/// collection.
10+
public let source: Int
11+
12+
/// The offset of the section in the current version of the sectioned
13+
/// collection.
14+
public let destination: Int
15+
16+
/// Represents changes within the section.
17+
///
18+
/// If the changeset specifies no change, it implies that only the
19+
/// section metadata has been changed.
20+
public let changeset: Changeset
21+
22+
public init(source: Int, destination: Int, changeset: Changeset) {
23+
self.source = source
24+
self.destination = destination
25+
self.changeset = changeset
26+
}
27+
}
28+
29+
/// The changes of sections.
30+
///
31+
/// - precondition: Offsets in `sections.mutations` and `sections.moves` must have a
32+
/// corresponding entry in `mutatedSections` if they represent a
33+
/// mutation.
34+
public var sections = Changeset()
35+
36+
/// The changes of items in the mutated sections.
37+
///
38+
/// - precondition: `mutatedSections` must have an entry for every mutated
39+
/// sections specified by `sections.mutations` and
40+
/// `sections.moves`.
41+
public var mutatedSections: [MutatedSection] = []
42+
43+
public init(sections: Changeset = Changeset(), mutatedSections: [MutatedSection] = []) {
44+
(self.sections, self.mutatedSections) = (sections, mutatedSections)
45+
}
46+
47+
public init<C: Collection>(initial: C) {
48+
sections.inserts.insert(integersIn: 0 ..< Int(initial.count))
49+
}
50+
51+
/// Compute the difference of a collection from its previous version.
52+
///
53+
/// The algorithm works best with collections of uniquely identified values.
54+
///
55+
/// If the multiple elements are bound to the same identifier, the algorithm
56+
/// would compute shortest moves at best effort, and removals or insertions
57+
/// depending on the change in the occurences.
58+
///
59+
/// - parameters:
60+
/// - previous: The previous version of the collection.
61+
/// - current: The collection.
62+
/// - sectionIdentifier: A lense to extract the unique identifier of the
63+
/// given section.
64+
/// - areMetadataEqual: A predicate to evaluate equality of the two given
65+
/// sections, which need not take account of the
66+
/// items.
67+
/// - items: A lense to extract items from a given section.
68+
/// - itemIdentifier: A lense to extract the unique identifier of the
69+
/// given item.
70+
/// - areItemsEqual: A predicate to evaluate equality of the two given
71+
/// items.
72+
///
73+
/// - complexity: O(n) time and space.
74+
public init<Sections: Collection, Items: Collection, SectionIdentifier: Hashable, ItemIdentifier: Hashable>(
75+
previous: Sections,
76+
current: Sections,
77+
sectionIdentifier: (Sections.Element) -> SectionIdentifier,
78+
areMetadataEqual: (Sections.Element, Sections.Element) -> Bool,
79+
items: (Sections.Element) -> Items,
80+
itemIdentifier: (Items.Element) -> ItemIdentifier,
81+
areItemsEqual: (Items.Element, Items.Element) -> Bool
82+
) {
83+
let metadata = Changeset(previous: previous, current: current, identifier: sectionIdentifier, areEqual: areMetadataEqual)
84+
85+
let moveSourceLookup = Dictionary(uniqueKeysWithValues: metadata.moves.lazy.map { ($0.destination, $0.source) })
86+
let mutatedMoveDests = Set(metadata.moves.lazy.filter { $0.isMutated }.map { $0.destination })
87+
88+
let allInsertions = metadata.inserts.union(IndexSet(moveSourceLookup.keys))
89+
let allRemovals = metadata.removals.union(IndexSet(moveSourceLookup.values))
90+
91+
mutatedSections = Array()
92+
mutatedSections.reserveCapacity(Int(current.count))
93+
94+
var moves: [Changeset.Move] = []
95+
var mutations = IndexSet()
96+
97+
for (offset, section) in current.enumerated() where !metadata.inserts.contains(offset) {
98+
let predeletionOffset: Int
99+
let isMove: Bool
100+
101+
if let moveSource = moveSourceLookup[offset] {
102+
predeletionOffset = moveSource
103+
isMove = true
104+
} else {
105+
let preinsertionOffset = offset - allInsertions.count(in: 0 ..< offset)
106+
predeletionOffset = preinsertionOffset + allRemovals.count(in: 0 ... preinsertionOffset)
107+
isMove = false
108+
}
109+
110+
let previousIndex = previous.index(previous.startIndex, offsetBy: numericCast(predeletionOffset))
111+
let previousItems = items(previous[previousIndex])
112+
let currentItems = items(section)
113+
114+
let changeset = Changeset(previous: previousItems,
115+
current: currentItems,
116+
identifier: itemIdentifier,
117+
areEqual: areItemsEqual)
118+
119+
let isMutated = !changeset.hasNoChanges
120+
|| metadata.mutations.contains(predeletionOffset)
121+
|| mutatedMoveDests.contains(offset)
122+
123+
if isMutated {
124+
let section = SectionedChangeset.MutatedSection(source: predeletionOffset,
125+
destination: offset,
126+
changeset: changeset)
127+
mutatedSections.append(section)
128+
}
129+
130+
if isMove {
131+
moves.append(Changeset.Move(source: predeletionOffset,
132+
destination: offset,
133+
isMutated: isMutated))
134+
} else if isMutated {
135+
mutations.insert(predeletionOffset)
136+
}
137+
}
138+
139+
sections = Changeset(inserts: metadata.inserts,
140+
removals: metadata.removals,
141+
mutations: mutations,
142+
moves: moves)
143+
}
144+
}
145+
146+
extension SectionedChangeset: CustomDebugStringConvertible {
147+
public var debugDescription: String {
148+
let contents: [String] = [
149+
sections.debugDescription,
150+
"- changesets of mutated sections: <<<",
151+
mutatedSections
152+
.map { entry in
153+
return " section \(entry.source) -> \(entry.destination)\n"
154+
+ entry.changeset.debugDescription
155+
.split(separator: "\n")
156+
.map { " \($0)" }
157+
.joined(separator: "\n")
158+
}
159+
.joined(separator: "\n"),
160+
" >>>",
161+
]
162+
return contents.joined(separator: "\n")
163+
}
164+
}
165+
166+
extension SectionedChangeset: Equatable {
167+
public static func == (lhs: SectionedChangeset, rhs: SectionedChangeset) -> Bool {
168+
func sourceOffsetIncreasingOrder(_ lhs: MutatedSection, _ rhs: MutatedSection) -> Bool {
169+
return lhs.source < rhs.source
170+
}
171+
172+
return lhs.sections == rhs.sections
173+
&& lhs.mutatedSections.sorted(by: sourceOffsetIncreasingOrder) == rhs.mutatedSections.sorted(by: sourceOffsetIncreasingOrder)
174+
}
175+
}
176+
177+
extension SectionedChangeset.MutatedSection: Equatable {
178+
public static func == (lhs: SectionedChangeset.MutatedSection, rhs: SectionedChangeset.MutatedSection) -> Bool {
179+
return lhs.source == rhs.source
180+
&& lhs.destination == rhs.destination
181+
&& lhs.changeset == rhs.changeset
182+
}
183+
}

FlexibleDiffTests/ChangesetSpec.swift

Lines changed: 6 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -820,8 +820,7 @@ private func diffTest<C: RangeReplaceableCollection>(
820820
reproducibilityTest(applying: changeset, to: previous, expecting: current, areEqual: areEqual, file: file, line: line)
821821
}
822822

823-
824-
private func reproducibilityTest<C: RangeReplaceableCollection>(
823+
internal func reproducibilityTest<C: RangeReplaceableCollection>(
825824
applying changeset: Changeset,
826825
to previous: C,
827826
expecting current: C,
@@ -831,51 +830,18 @@ private func reproducibilityTest<C: RangeReplaceableCollection>(
831830
reproducibilityTest(applying: changeset, to: previous, expecting: current, areEqual: ==, file: file, line: line)
832831
}
833832

834-
private func reproducibilityTest<C: RangeReplaceableCollection>(
833+
internal func reproducibilityTest<C: RangeReplaceableCollection>(
835834
applying changeset: Changeset,
836835
to previous: C,
837836
expecting current: C,
838837
areEqual: (@escaping (C.Iterator.Element, C.Iterator.Element) -> Bool),
839838
file: FileString = #file,
840839
line: UInt = #line
841840
) {
842-
var values = previous
843-
expect(values).to(equal(previous, by: areEqual))
844-
845-
// Move offset pairs are only a hint for animation and optimization. They are
846-
// semantically equivalent to a removal offset paired with an insertion offset.
847-
let removals = changeset.removals.union(IndexSet(changeset.moves.lazy.map { $0.source }))
848-
let inserts = changeset.inserts.union(IndexSet(changeset.moves.lazy.map { $0.destination }))
849-
850-
// (1) Perform removals (including move sources).
851-
for range in removals.rangeView.reversed() {
852-
let lowerBound = values.index(values.startIndex, offsetBy: numericCast(range.lowerBound))
853-
let upperBound = values.index(lowerBound, offsetBy: numericCast(range.count))
854-
values.removeSubrange(lowerBound ..< upperBound)
855-
}
856-
857-
// (2) Copy position invariant mutations.
858-
for range in changeset.mutations.rangeView {
859-
let removalOffset = removals.count(in: 0 ..< range.lowerBound)
860-
let insertOffset = inserts.count(in: 0 ... range.lowerBound)
861-
862-
let lowerBound = values.index(values.startIndex, offsetBy: numericCast(range.lowerBound - removalOffset))
863-
let upperBound = values.index(lowerBound, offsetBy: numericCast(range.count))
864-
let copyLowerBound = current.index(current.startIndex, offsetBy: numericCast(range.lowerBound - removalOffset + insertOffset))
865-
let copyUpperBound = current.index(copyLowerBound, offsetBy: numericCast(range.count))
866-
values.replaceSubrange(lowerBound ..< upperBound,
867-
with: current[copyLowerBound ..< copyUpperBound])
868-
}
869-
870-
// (3) Perform insertions (including move destinations).
871-
for range in inserts.rangeView {
872-
let lowerBound = values.index(values.startIndex, offsetBy: numericCast(range.lowerBound))
873-
let copyLowerBound = current.index(current.startIndex, offsetBy: numericCast(range.lowerBound))
874-
let copyUpperBound = current.index(copyLowerBound, offsetBy: numericCast(range.count))
875-
values.insert(contentsOf: current[copyLowerBound ..< copyUpperBound],
876-
at: lowerBound)
877-
}
878-
841+
let values = reproduce(applying: changeset,
842+
to: previous,
843+
expecting: current,
844+
areEqual: areEqual)
879845
expect(values, file: file, line: line).to(equal(current, original: previous, changeset: changeset, by: areEqual))
880846
}
881847

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
import FlexibleDiff
3+
4+
internal func reproduce<C: RangeReplaceableCollection>(
5+
applying changeset: Changeset,
6+
to previous: C,
7+
expecting current: C,
8+
areEqual: (@escaping (C.Iterator.Element, C.Iterator.Element) -> Bool)
9+
) -> C {
10+
var values = previous
11+
12+
// Move offset pairs are only a hint for animation and optimization. They are
13+
// semantically equivalent to a removal offset paired with an insertion offset.
14+
let removals = changeset.removals.union(IndexSet(changeset.moves.lazy.map { $0.source }))
15+
let inserts = changeset.inserts.union(IndexSet(changeset.moves.lazy.map { $0.destination }))
16+
17+
// (1) Perform removals (including move sources).
18+
for range in removals.rangeView.reversed() {
19+
let lowerBound = values.index(values.startIndex, offsetBy: numericCast(range.lowerBound))
20+
let upperBound = values.index(lowerBound, offsetBy: numericCast(range.count))
21+
values.removeSubrange(lowerBound ..< upperBound)
22+
}
23+
24+
// (2) Copy position invariant mutations.
25+
for range in changeset.mutations.rangeView {
26+
let removalOffset = removals.count(in: 0 ..< range.lowerBound)
27+
let insertOffset = inserts.count(in: 0 ... range.lowerBound)
28+
29+
let lowerBound = values.index(values.startIndex, offsetBy: numericCast(range.lowerBound - removalOffset))
30+
let upperBound = values.index(lowerBound, offsetBy: numericCast(range.count))
31+
let copyLowerBound = current.index(current.startIndex, offsetBy: numericCast(range.lowerBound - removalOffset + insertOffset))
32+
let copyUpperBound = current.index(copyLowerBound, offsetBy: numericCast(range.count))
33+
values.replaceSubrange(lowerBound ..< upperBound,
34+
with: current[copyLowerBound ..< copyUpperBound])
35+
}
36+
37+
// (3) Perform insertions (including move destinations).
38+
for range in inserts.rangeView {
39+
let lowerBound = values.index(values.startIndex, offsetBy: numericCast(range.lowerBound))
40+
let copyLowerBound = current.index(current.startIndex, offsetBy: numericCast(range.lowerBound))
41+
let copyUpperBound = current.index(copyLowerBound, offsetBy: numericCast(range.count))
42+
values.insert(contentsOf: current[copyLowerBound ..< copyUpperBound],
43+
at: lowerBound)
44+
}
45+
46+
return values
47+
}
48+

0 commit comments

Comments
 (0)