Skip to content

Commit

Permalink
Patch operations
Browse files Browse the repository at this point in the history
  • Loading branch information
theolampert committed Oct 27, 2024
1 parent 6956b44 commit 3990115
Show file tree
Hide file tree
Showing 2 changed files with 76 additions and 66 deletions.
58 changes: 22 additions & 36 deletions Sources/Quilt/Quilt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,33 @@ public struct Quilt: Codable, Sendable {
private var counter: Int = 0
private let user: UUID

public var operationLog: ContiguousArray<Operation> = []
public private(set) var operationLog: ContiguousArray<Operation> = []

public private(set) var currentContent: [Operation] = []

public init(user: UUID) {
self.user = user
}

private mutating func applyOperations() {
var ops: [Operation] = []
/*
Optimisation: Assume that text is inserted and removed sequentially
and cache the last known index to avoid scanning the whole document.
This was cribbed from how Y.js does it.
*/
var lastIdx: (OpID, Int)?
private mutating func patch(_ operation: Operation) {
if case .insert = operation.type {
if operation.afterId == nil {
currentContent.insert(operation, at: 0)
} else if let idx = currentContent.firstIndex(where: { $0.opId == operation.afterId }) {
currentContent.insert(operation, at: idx + 1)
}
} else if case let .remove(removeID) = operation.type {
if let idx = currentContent.firstIndex(where: { $0.opId == removeID }) {
currentContent.remove(at: idx)
}
}
}

public mutating func commit() {
currentContent = []
for operation in operationLog {
if case .insert = operation.type {
if operation.afterId == nil {
ops.insert(operation, at: 0)
lastIdx = (operation.opId, 0)
} else if lastIdx?.0 == operation.afterId {
let newIdx = lastIdx!.1 + 1
ops.insert(operation, at: newIdx)
lastIdx = (operation.opId, newIdx)
} else if let idx = ops.firstIndex(where: { $0.opId == operation.afterId }) {
let newIdx = idx + 1
ops.insert(operation, at: newIdx)
lastIdx = (operation.opId, newIdx)
}
} else if case let .remove(removeID) = operation.type {
if let idx = ops.firstIndex(where: { $0.opId == removeID }) {
ops.remove(at: idx)
if removeID == lastIdx?.0 {
lastIdx = nil
}
}
}
patch(operation)
}
currentContent = ops
}

/// Inserts a character at the specified index in the text
Expand All @@ -60,7 +46,7 @@ public struct Quilt: Codable, Sendable {
)
operationLog.append(operation)
counter += 1
applyOperations()
patch(operation)
}

/// Removes the character at the specified index
Expand All @@ -75,7 +61,7 @@ public struct Quilt: Codable, Sendable {
)
operationLog.append(operation)
counter += 1
applyOperations()
patch(operation)
}

/// Adds a formatting mark to a range of text
Expand All @@ -97,7 +83,7 @@ public struct Quilt: Codable, Sendable {
)
operationLog.append(operation)
counter += 1
applyOperations()
commit()
}

/// Removes a formatting mark from a range of text
Expand All @@ -122,7 +108,7 @@ public struct Quilt: Codable, Sendable {
)
operationLog.append(operation)
counter += 1
applyOperations()
commit()
}

/// Merges another Quilt document into this one
Expand All @@ -136,6 +122,6 @@ public struct Quilt: Codable, Sendable {
})?.opId.counter {
counter = max + 1
}
applyOperations()
commit()
}
}
84 changes: 54 additions & 30 deletions Tests/QuiltTests/QuiltTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,17 @@
import Foundation
import Testing
@testable import Quilt
import XCTest

let user = UUID(uuidString: "E2DFDB75-A3D9-4B55-9312-111EF297D566")!
let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!


@Test func testAddRemoveOps() throws {
var quilt = Quilt(user: user)

quilt.insert(character: "T", atIndex: 0)

XCTAssertEqual(
quilt.operationLog[0],
Operation(
#expect(
quilt.operationLog[0] == Operation(
opId: OpID(counter: 0, id: user),
type: .insert("T"),
afterId: nil
Expand All @@ -30,9 +27,8 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!

quilt.insert(character: "H", atIndex: 1)

XCTAssertEqual(
quilt.operationLog[1],
Operation(
#expect(
quilt.operationLog[1] == Operation(
opId: OpID(counter: 1, id: user),
type: .insert("H"),
afterId: quilt.operationLog[0].id
Expand All @@ -41,9 +37,8 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!

quilt.remove(atIndex: 1)

XCTAssertEqual(
quilt.operationLog[2],
Operation(
#expect(
quilt.operationLog[2] == Operation(
opId: OpID(counter: 2, id: user),
type: .remove(quilt.operationLog[1].id)
)
Expand All @@ -60,7 +55,7 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!

quilt.addMark(mark: .bold, fromIndex: 0, toIndex: 4)

XCTAssertEqual(quilt.operationLog[5], Operation(
#expect(quilt.operationLog[5] == Operation(
opId: OpID(counter: 5, id: user),
type: .addMark(
type: .bold,
Expand All @@ -75,7 +70,7 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!
toIndex: 4
)

XCTAssertEqual(quilt.operationLog[6], Operation(
#expect(quilt.operationLog[6] == Operation(
opId: OpID(counter: 6, id: user),
type: .removeMark(
type: .bold,
Expand All @@ -88,10 +83,10 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!
@Test func testArraySafeIndex() {
let array = [1, 2, 3]

XCTAssertEqual(array[safeIndex: 0], 1)
XCTAssertEqual(array[safeIndex: 2], 3)
XCTAssertNil(array[safeIndex: -1])
XCTAssertNil(array[safeIndex: 3])
#expect(array[safeIndex: 0] == 1)
#expect(array[safeIndex: 2] == 3)
#expect(array[safeIndex: -1] == nil)
#expect(array[safeIndex: 3] == nil)
}

// MARK: - OpID Tests
Expand All @@ -101,18 +96,18 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!
let id2 = OpID(counter: 2, id: user)
let id3 = OpID(counter: 2, id: otherUser)

XCTAssertLessThan(id1, id2)
XCTAssertGreaterThan(id2, id1)
#expect(id1 < id2)
#expect(id2 > id1)

// Test same counter, different UUIDs
XCTAssertNotEqual(id2, id3)
#expect(id2 != id3)
// Since user UUID < otherUser UUID
XCTAssertLessThan(id2, id3)
#expect(id2 < id3)
}

@Test func testOpIDDescription() {
let id = OpID(counter: 42, id: user)
XCTAssertEqual(id.description, "42@\(user)")
#expect(id.description == "42@\(user)")
}

// MARK: - Quilt Merge Tests
Expand All @@ -126,23 +121,22 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!

quilt1.merge(quilt2)

XCTAssertEqual(quilt1.operationLog.count, 2)
XCTAssertEqual(quilt1.currentContent.count, 2)
#expect(quilt1.operationLog.count == 2)
#expect(quilt1.currentContent.count == 2)
}

@Test func testMergeDuplicateOperations() {
var quilt1 = Quilt(user: user)
var quilt2 = Quilt(user: otherUser) // Changed to different user

quilt1.insert(character: "A", atIndex: 0)
quilt2.operationLog = quilt1.operationLog
quilt2.insert(character: "B", atIndex: 1)

quilt1.merge(quilt2)

// Should add both operationLog since they're from different users
XCTAssertEqual(quilt1.operationLog.count, 2)
XCTAssertEqual(quilt1.currentContent.count, 2)
#expect(quilt1.operationLog.count == 2)
#expect(quilt1.currentContent.count == 2)
}

// MARK: - Edge Cases Tests
Expand All @@ -152,7 +146,7 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!

// Should not crash
quilt.remove(atIndex: 0)
XCTAssertEqual(quilt.operationLog.count, 0)
#expect(quilt.operationLog.count == 0)
}

@Test func testRemoveFromInvalidIndex() {
Expand All @@ -161,13 +155,43 @@ let otherUser = UUID(uuidString: "F3DFDB75-A3D9-4B55-9312-111EF297D567")!

// Should not crash and should add the remove operation
quilt.remove(atIndex: 1)
XCTAssertEqual(quilt.operationLog.count, 2)
#expect(quilt.operationLog.count == 2)
}

@Test func testInsertAtInvalidIndex() {
var quilt = Quilt(user: user)

// Should still work by inserting at the end
quilt.insert(character: "A", atIndex: 999)
XCTAssertEqual(quilt.operationLog.count, 1)
#expect(quilt.operationLog.count == 1)
}

@Test func testPerf() {
var quilt = Quilt(user: user)

let str = """
So fare thee well, poor devil of a Sub-Sub, whose commen-
tator I am. Thou belongest to that hopeless, sallow tribe
which no wine of this world will ever warm ; and for whom
even Pale Sherry would be too rosy-strong ; but with whom
one sometimes loves to sit, and feel poor-devilish, too ; and
grow convivial upon tears ; and say to them bluntly with full
eyes and empty glasses, and in not altogether unpleasant
sadness Give it up, Sub-Subs ! For by how much the more
pains ye take to please the world, by so much the more shall
ye forever go thankless ! Would that I could clear out
Hampton Court and the Tuileries for ye ! But gulp down
your tears and hie aloft to the royal-mast with your hearts ;
for your friends who have gone before are clearing out the
seven-storied heavens, and making refugees of long-pampered
Gabriel, Michael, and Raphael, against your coming. Here
ye strike but splintered hearts together there, ye shall
strike unsplinterable glasses!
"""

str.enumerated().forEach { (idx, char)in
quilt.insert(character: char, atIndex: idx)
}

#expect(quilt.operationLog.count == 982)
}

0 comments on commit 3990115

Please sign in to comment.