diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 50feee21f..21c33e343 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -163,7 +163,7 @@ class Highlighter: NSObject { let difference = newIds.difference(from: existingIds).inferringMoves() var highlightProviders = self.highlightProviders // Make a mutable copy - var moveMap: [Int: HighlightProviderState] = [:] + var moveMap: [Int: (Int, HighlightProviderState)] = [:] for change in difference { switch change { @@ -174,7 +174,8 @@ class Highlighter: NSObject { guard let movedProvider = moveMap[offset] else { continue } - highlightProviders.insert(movedProvider, at: offset) + highlightProviders.insert(movedProvider.1, at: offset) + styleContainer.setPriority(providerId: movedProvider.0, priority: offset) continue } // Set up a new provider and insert it with a unique ID @@ -188,12 +189,12 @@ class Highlighter: NSObject { language: language ) highlightProviders.insert(state, at: offset) - styleContainer.addProvider(providerIdCounter, documentLength: textView.length) + styleContainer.addProvider(providerIdCounter, priority: offset, documentLength: textView.length) state.invalidate() // Invalidate this new one case let .remove(offset, _, associatedOffset): - guard associatedOffset == nil else { + if let associatedOffset { // Moved, add it to the move map - moveMap[associatedOffset!] = highlightProviders.remove(at: offset) + moveMap[associatedOffset] = (offset, highlightProviders.remove(at: offset)) continue } // Removed entirely diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer+runsIn.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer+runsIn.swift new file mode 100644 index 000000000..69e6a93ca --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer+runsIn.swift @@ -0,0 +1,72 @@ +// +// StyledRangeContainer+runsIn.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/18/25. +// + +import Foundation + +extension StyledRangeContainer { + /// Coalesces all styled runs into a single continuous array of styled runs. + /// + /// When there is an overlapping, conflicting style (eg: provider 2 gives `.comment` to the range `0..<2`, and + /// provider 1 gives `.string` to `1..<2`), the provider with a lower identifier will be prioritized. In the example + /// case, the final value would be `0..<1=.comment` and `1..<2=.string`. + /// + /// - Parameter range: The range to query. + /// - Returns: An array of continuous styled runs. + func runsIn(range: NSRange) -> [RangeStoreRun] { + func combineLowerPriority(_ lhs: inout RangeStoreRun, _ rhs: RangeStoreRun) { + lhs.value = lhs.value?.combineLowerPriority(rhs.value) ?? rhs.value + } + + func combineHigherPriority(_ lhs: inout RangeStoreRun, _ rhs: RangeStoreRun) { + lhs.value = lhs.value?.combineHigherPriority(rhs.value) ?? rhs.value + } + + // Ordered by priority, lower = higher priority. + var allRuns = _storage.values + .sorted(by: { $0.priority < $1.priority }) + .map { $0.store.runs(in: range.intRange) } + + var runs: [RangeStoreRun] = [] + var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) + var counter = 0 + + while let value = minValue { + // Get minimum length off the end of each array + let minRunIdx = value.offset + var minRun = value.element + + for idx in (0.. 0, "Empty or negative runs are not allowed.") + runs.append(minRun) + minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) + + counter += 1 + } + + return runs.reversed() + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index f0677416b..da1966b31 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -70,7 +70,7 @@ class StyledRangeContainer { } } - var _storage: [ProviderID: RangeStore] = [:] + var _storage: [ProviderID: (store: RangeStore, priority: Int)] = [:] weak var delegate: StyledRangeContainerDelegate? /// Initialize the container with a list of provider identifiers. Each provider is given an id, they should be @@ -80,17 +80,21 @@ class StyledRangeContainer { /// - providers: An array of identifiers given to providers. init(documentLength: Int, providers: [ProviderID]) { for provider in providers { - _storage[provider] = RangeStore(documentLength: documentLength) + _storage[provider] = (store: RangeStore(documentLength: documentLength), priority: provider) } } - func addProvider(_ id: ProviderID, documentLength: Int) { + func addProvider(_ id: ProviderID, priority: Int, documentLength: Int) { assert(!_storage.keys.contains(id), "Provider already exists") - _storage[id] = RangeStore(documentLength: documentLength) + _storage[id] = (store: RangeStore(documentLength: documentLength), priority: priority) + } + + func setPriority(providerId: ProviderID, priority: Int) { + _storage[providerId]?.priority = priority } func removeProvider(_ id: ProviderID) { - guard let provider = _storage[id] else { return } + guard let provider = _storage[id]?.store else { return } applyHighlightResult( provider: id, highlights: [], @@ -99,64 +103,9 @@ class StyledRangeContainer { _storage.removeValue(forKey: id) } - /// Coalesces all styled runs into a single continuous array of styled runs. - /// - /// When there is an overlapping, conflicting style (eg: provider 2 gives `.comment` to the range `0..<2`, and - /// provider 1 gives `.string` to `1..<2`), the provider with a lower identifier will be prioritized. In the example - /// case, the final value would be `0..<1=.comment` and `1..<2=.string`. - /// - /// - Parameter range: The range to query. - /// - Returns: An array of continuous styled runs. - func runsIn(range: NSRange) -> [RangeStoreRun] { - func combineLowerPriority(_ lhs: inout RangeStoreRun, _ rhs: RangeStoreRun) { - lhs.value = lhs.value?.combineLowerPriority(rhs.value) ?? rhs.value - } - - func combineHigherPriority(_ lhs: inout RangeStoreRun, _ rhs: RangeStoreRun) { - lhs.value = lhs.value?.combineHigherPriority(rhs.value) ?? rhs.value - } - - // Ordered by priority, lower = higher priority. - var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } - var runs: [RangeStoreRun] = [] - - var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) - - while let value = minValue { - // Get minimum length off the end of each array - let minRunIdx = value.offset - var minRun = value.element - - for idx in (0..: Sendable { /// - Parameter range: The range to query. /// - Returns: A continuous array of runs representing the queried range. func runs(in range: Range) -> [Run] { + let length = _guts.count(in: OffsetMetric()) assert(range.lowerBound >= 0, "Negative lowerBound") - assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") + assert(range.upperBound <= length, "upperBound outside valid range") if let cache, cache.range == range { return cache.runs } var runs = [Run]() - var index = findIndex(at: range.lowerBound).index - var offset: Int? = range.lowerBound - _guts.offset(of: index, in: OffsetMetric()) + var offset: Int = range.lowerBound - _guts.offset(of: index, in: OffsetMetric()) + var remainingLength = range.upperBound - range.lowerBound - while index < _guts.endIndex, _guts.offset(of: index, in: OffsetMetric()) < range.upperBound { + while index < _guts.endIndex, + _guts.offset(of: index, in: OffsetMetric()) < range.upperBound, + remainingLength > 0 { let run = _guts[index] - runs.append(Run(length: run.length - (offset ?? 0), value: run.value)) + let runLength = min(run.length - offset, remainingLength) + runs.append(Run(length: runLength, value: run.value)) + remainingLength -= runLength + if remainingLength <= 0 { + break // Avoid even checking the storage for the next index + } index = _guts.index(after: index) - offset = nil + offset = 0 } return runs @@ -83,6 +91,9 @@ struct RangeStore: Sendable { storageUpdated(replacedCharactersIn: upperBound.. - override var continueAfterFailure: Bool { - get { false } - set { } - } - - func test_initWithLength() { + @Test + func initWithLength() { for _ in 0..<100 { let length = Int.random(in: 0..<1000) var store = Store(documentLength: length) - XCTAssertEqual(store.length, length) + #expect(store.length == length) } } // MARK: - Storage - func test_storageRemoveCharacters() { + @Test + func storageRemoveCharacters() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 10..<12, withCount: 0) - XCTAssertEqual(store.length, 98, "Failed to remove correct range") - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 98, "Failed to remove correct range") + #expect(store.count == 1, "Failed to coalesce") } - func test_storageRemoveFromEnd() { + @Test + func storageRemoveFromEnd() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 95..<100, withCount: 0) - XCTAssertEqual(store.length, 95, "Failed to remove correct range") - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 95, "Failed to remove correct range") + #expect(store.count == 1, "Failed to coalesce") } - func test_storageRemoveSingleCharacterFromEnd() { + @Test + func storageRemoveSingleCharacterFromEnd() { var store = Store(documentLength: 10) store.set( // Test that we can delete a character associated with a single syntax run too runs: [ @@ -49,122 +49,135 @@ final class RangeStoreTests: XCTestCase { for: 0..<10 ) store.storageUpdated(replacedCharactersIn: 9..<10, withCount: 0) - XCTAssertEqual(store.length, 9, "Failed to remove correct range") - XCTAssertEqual(store.count, 2) + #expect(store.length == 9, "Failed to remove correct range") + #expect(store.count == 2) } - func test_storageRemoveFromBeginning() { + @Test + func storageRemoveFromBeginning() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<15, withCount: 0) - XCTAssertEqual(store.length, 85, "Failed to remove correct range") - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 85, "Failed to remove correct range") + #expect(store.count == 1, "Failed to coalesce") } - func test_storageRemoveAll() { + @Test + func storageRemoveAll() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<100, withCount: 0) - XCTAssertEqual(store.length, 0, "Failed to remove correct range") - XCTAssertEqual(store.count, 0, "Failed to remove all runs") + #expect(store.length == 0, "Failed to remove correct range") + #expect(store.count == 0, "Failed to remove all runs") } - func test_storageInsert() { + @Test + func storageInsert() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 45..<45, withCount: 10) - XCTAssertEqual(store.length, 110) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 110) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageInsertAtEnd() { + @Test + func storageInsertAtEnd() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 100..<100, withCount: 10) - XCTAssertEqual(store.length, 110) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 110) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageInsertAtBeginning() { + @Test + func storageInsertAtBeginning() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<0, withCount: 10) - XCTAssertEqual(store.length, 110) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 110) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageInsertFromEmpty() { + @Test + func storageInsertFromEmpty() { var store = Store(documentLength: 0) store.storageUpdated(replacedCharactersIn: 0..<0, withCount: 10) - XCTAssertEqual(store.length, 10) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 10) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageEdit() { + @Test + func storageEdit() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 45..<50, withCount: 10) - XCTAssertEqual(store.length, 105) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 105) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageEditAtEnd() { + @Test + func storageEditAtEnd() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 95..<100, withCount: 10) - XCTAssertEqual(store.length, 105) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 105) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageEditAtBeginning() { + @Test + func storageEditAtBeginning() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<5, withCount: 10) - XCTAssertEqual(store.length, 105) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 105) + #expect(store.count == 1, "Failed to coalesce") } - func test_storageEditAll() { + @Test + func storageEditAll() { var store = Store(documentLength: 100) store.storageUpdated(replacedCharactersIn: 0..<100, withCount: 10) - XCTAssertEqual(store.length, 10) - XCTAssertEqual(store.count, 1, "Failed to coalesce") + #expect(store.length == 10) + #expect(store.count == 1, "Failed to coalesce") } // MARK: - Styles - func test_setOneRun() { + @Test + func setOneRun() { var store = Store(documentLength: 100) store.set(value: .init(capture: .comment, modifiers: [.static]), for: 45..<50) - XCTAssertEqual(store.length, 100) - XCTAssertEqual(store.count, 3) + #expect(store.length == 100) + #expect(store.count == 3) let runs = store.runs(in: 0..<100) - XCTAssertEqual(runs.count, 3) - XCTAssertEqual(runs[0].length, 45) - XCTAssertEqual(runs[1].length, 5) - XCTAssertEqual(runs[2].length, 50) - - XCTAssertNil(runs[0].value?.capture) - XCTAssertEqual(runs[1].value?.capture, .comment) - XCTAssertNil(runs[2].value?.capture) - - XCTAssertEqual(runs[0].value?.modifiers, nil) - XCTAssertEqual(runs[1].value?.modifiers, [.static]) - XCTAssertEqual(runs[2].value?.modifiers, nil) + #expect(runs.count == 3) + #expect(runs[0].length == 45) + #expect(runs[1].length == 5) + #expect(runs[2].length == 50) + + #expect(runs[0].value?.capture == nil) + #expect(runs[1].value?.capture == .comment) + #expect(runs[2].value?.capture == nil) + + #expect(runs[0].value?.modifiers == nil) + #expect(runs[1].value?.modifiers == [.static]) + #expect(runs[2].value?.modifiers == nil) } - func test_queryOverlappingRun() { + @Test + func queryOverlappingRun() { var store = Store(documentLength: 100) store.set(value: .init(capture: .comment, modifiers: [.static]), for: 45..<50) - XCTAssertEqual(store.length, 100) - XCTAssertEqual(store.count, 3) + #expect(store.length == 100) + #expect(store.count == 3) let runs = store.runs(in: 47..<100) - XCTAssertEqual(runs.count, 2) - XCTAssertEqual(runs[0].length, 3) - XCTAssertEqual(runs[1].length, 50) + #expect(runs.count == 2) + #expect(runs[0].length == 3) + #expect(runs[1].length == 50) - XCTAssertEqual(runs[0].value?.capture, .comment) - XCTAssertNil(runs[1].value?.capture) + #expect(runs[0].value?.capture == .comment) + #expect(runs[1].value?.capture == nil) - XCTAssertEqual(runs[0].value?.modifiers, [.static]) - XCTAssertEqual(runs[1].value?.modifiers, nil) + #expect(runs[0].value?.modifiers == [.static]) + #expect(runs[1].value?.modifiers == nil) } - func test_setMultipleRuns() { + @Test + func setMultipleRuns() { var store = Store(documentLength: 100) store.set(value: .init(capture: .comment, modifiers: [.static]), for: 5..<15) @@ -173,24 +186,25 @@ final class RangeStoreTests: XCTestCase { store.set(value: .init(capture: .function, modifiers: []), for: 45..<50) store.set(value: .init(capture: .variable, modifiers: []), for: 60..<70) - XCTAssertEqual(store.length, 100) + #expect(store.length == 100) let runs = store.runs(in: 0..<100) - XCTAssertEqual(runs.count, 11) - XCTAssertEqual(runs.reduce(0, { $0 + $1.length }), 100) + #expect(runs.count == 11) + #expect(runs.reduce(0, { $0 + $1.length }) == 100) let lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] let captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] let modifiers: [CaptureModifierSet] = [[], [.static], [], [], [], [.static], [], [], [], [], []] runs.enumerated().forEach { - XCTAssertEqual($0.element.length, lengths[$0.offset]) - XCTAssertEqual($0.element.value?.capture, captures[$0.offset]) - XCTAssertEqual($0.element.value?.modifiers ?? [], modifiers[$0.offset]) + #expect($0.element.length == lengths[$0.offset]) + #expect($0.element.value?.capture == captures[$0.offset]) + #expect($0.element.value?.modifiers ?? [] == modifiers[$0.offset]) } } - func test_setMultipleRunsAndStorageUpdate() { + @Test + func setMultipleRunsAndStorageUpdate() { var store = Store(documentLength: 100) var lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] @@ -204,43 +218,78 @@ final class RangeStoreTests: XCTestCase { for: 0..<100 ) - XCTAssertEqual(store.length, 100) + #expect(store.length == 100) var runs = store.runs(in: 0..<100) - XCTAssertEqual(runs.count, 11) - XCTAssertEqual(runs.reduce(0, { $0 + $1.length }), 100) + #expect(runs.count == 11) + #expect(runs.reduce(0, { $0 + $1.length }) == 100) runs.enumerated().forEach { - XCTAssertEqual( - $0.element.length, - lengths[$0.offset], + #expect( + $0.element.length == lengths[$0.offset], "Run \($0.offset) has incorrect length: \($0.element.length). Expected \(lengths[$0.offset])" ) - XCTAssertEqual( - $0.element.value?.capture, - captures[$0.offset], // swiftlint:disable:next line_length + #expect( + $0.element.value?.capture == captures[$0.offset], // swiftlint:disable:next line_length "Run \($0.offset) has incorrect capture: \(String(describing: $0.element.value?.capture)). Expected \(String(describing: captures[$0.offset]))" ) - XCTAssertEqual( - $0.element.value?.modifiers, - modifiers[$0.offset], // swiftlint:disable:next line_length + #expect( + $0.element.value?.modifiers == modifiers[$0.offset], // swiftlint:disable:next line_length "Run \($0.offset) has incorrect modifiers: \(String(describing: $0.element.value?.modifiers)). Expected \(modifiers[$0.offset])" ) } store.storageUpdated(replacedCharactersIn: 30..<45, withCount: 10) runs = store.runs(in: 0..<95) - XCTAssertEqual(runs.count, 9) - XCTAssertEqual(runs.reduce(0, { $0 + $1.length }), 95) + #expect(runs.count == 9) + #expect(runs.reduce(0, { $0 + $1.length }) == 95) lengths = [5, 10, 5, 10, 10, 5, 10, 10, 30] captures = [nil, .comment, nil, .keyword, nil, .function, nil, .variable, nil] modifiers = [[], [.static], [], [], [], [], [], [], []] runs.enumerated().forEach { - XCTAssertEqual($0.element.length, lengths[$0.offset]) - XCTAssertEqual($0.element.value?.capture, captures[$0.offset]) - XCTAssertEqual($0.element.value?.modifiers ?? [], modifiers[$0.offset]) + #expect($0.element.length == lengths[$0.offset]) + #expect($0.element.value?.capture == captures[$0.offset]) + #expect($0.element.value?.modifiers ?? [] == modifiers[$0.offset]) + } + } + + // MARK: - Query + + // A few known bad cases + @Test(arguments: [3..<8, 65..<100, 0..<5, 5..<12]) + func runsInAlwaysBoundedByRange(_ range: Range) { + var store = Store(documentLength: 100) + let lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] + let captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] + let modifiers: [CaptureModifierSet] = [[], [.static], [], [], [], [.static], [], [], [], [], []] + + store.set( + runs: zip(zip(lengths, captures), modifiers).map { + Store.Run(length: $0.0, value: .init(capture: $0.1, modifiers: $1)) + }, + for: 0..<100 + ) + + #expect( + store.runs(in: range).reduce(0, { $0 + $1.length }) == (range.upperBound - range.lowerBound), + "Runs returned by storage did not equal requested range" + ) + #expect(store.runs(in: range).allSatisfy({ $0.length > 0 })) + } + + // Randomized version of the previous test + @Test + func runsAlwaysBoundedByRangeRandom() { + func range() -> Range { + let start = Int.random(in: 0..<100) + let end = Int.random(in: start..<100) + return start..