From 62c17313a842d391585c75703d172de95d290667 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:20:22 -0500 Subject: [PATCH 01/24] Add Object Scaffolding, RangeStore, VisualRange Tests, Range Store Tests --- .../TextViewController+Highlighter.swift | 20 +- .../Controller/TextViewController.swift | 2 +- .../TextView+/TextView+createReadBlock.swift | 4 +- .../HighlightProviderState.swift | 160 +++++++++++ .../HighlightProviding.swift | 0 .../Highlighting/HighlightRange.swift | 2 +- .../Highlighter+NSTextStorageDelegate.swift | 36 --- .../Highlighting/Highlighter.swift | 176 ++++++------ .../Highlighting/HighlighterTextView.swift | 16 -- .../RangeStore+Node.swift | 250 ++++++++++++++++++ .../StyledRangeContainer/RangeStore.swift | 78 ++++++ .../StyledRangeContainer.swift | 18 ++ .../Highlighting/VisibleRangeProvider.swift | 77 ++++++ .../HighlighterTests.swift | 1 - .../Highlighting/RangeStoreBenchmarks.swift | 60 +++++ .../Highlighting/RangeStoreTests.swift | 69 +++++ .../VisibleRangeProviderTests.swift | 55 ++++ Tests/CodeEditSourceEditorTests/Mock.swift | 16 +- 18 files changed, 877 insertions(+), 163 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift rename Sources/CodeEditSourceEditor/Highlighting/{ => HighlighProviding}/HighlightProviding.swift (100%) delete mode 100644 Sources/CodeEditSourceEditor/Highlighting/Highlighter+NSTextStorageDelegate.swift delete mode 100644 Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreTests.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index 0ad8597e9..e646d39f3 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -11,18 +11,18 @@ import SwiftTreeSitter extension TextViewController { internal func setUpHighlighter() { if let highlighter { - textView.removeStorageDelegate(highlighter) +// textView.removeStorageDelegate(highlighter) self.highlighter = nil } - self.highlighter = Highlighter( - textView: textView, - highlightProvider: highlightProvider, - theme: theme, - attributeProvider: self, - language: language - ) - textView.addStorageDelegate(highlighter!) +// self.highlighter = Highlighter( +// textView: textView, +// highlightProvider: highlightProvider, +// theme: theme, +// attributeProvider: self, +// language: language +// ) +// textView.addStorageDelegate(highlighter!) setHighlightProvider(self.highlightProvider) } @@ -38,7 +38,7 @@ extension TextViewController { if let provider = provider { self.highlightProvider = provider - highlighter?.setHighlightProvider(provider) +// highlighter?.setHighlightProvider(provider) } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 4dbf282a2..9deef964e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -295,7 +295,7 @@ public class TextViewController: NSViewController { deinit { if let highlighter { - textView.removeStorageDelegate(highlighter) +// textView.removeStorageDelegate(highlighter) } highlighter = nil highlightProvider = nil diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift index c31475db0..fe3c06643 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift @@ -28,7 +28,7 @@ extension TextView { return nil } let range = NSRange(location.. SwiftTreeSitter.Predicate.TextProvider { return { [weak self] range, _ in let workItem: () -> String? = { - self?.stringForRange(range) + self?.textStorage.substring(from: range) } return DispatchQueue.syncMainIfNot(workItem) } diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift new file mode 100644 index 000000000..1445e8c06 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -0,0 +1,160 @@ +// +// HighlightProviderState.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/13/24. +// + +import Foundation +import CodeEditLanguages +import CodeEditTextView +import OSLog + +protocol HighlightProviderStateDelegate: AnyObject { + func applyHighlightResult(provider: UUID, highlights: [HighlightRange], rangeToHighlight: NSRange) +} + +@MainActor +class HighlightProviderState { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "HighlightProviderState") + + /// The length to chunk ranges into when passing to the highlighter. + private static let rangeChunkLimit = 4096 + + // MARK: - State + + /// A unique identifier for this provider. Used by the delegate to determine the source of results. + private let id: UUID = UUID() + + /// Any indexes that highlights have been requested for, but haven't been applied. + /// Indexes/ranges are added to this when highlights are requested and removed + /// after they are applied + private var pendingSet: IndexSet = .init() + + /// The set of valid indexes + private var validSet: IndexSet = .init() + + // MARK: - Providers + + private weak var delegate: HighlightProviderStateDelegate? + + /// Calculates invalidated ranges given an edit. + private weak var highlightProvider: HighlightProviding? + + /// Provides a constantly updated visible index set. + private weak var visibleRangeProvider: VisibleRangeProvider? + + /// A weak reference to the text view, used by the highlight provider. + private weak var textView: TextView? + + private var visibleSet: IndexSet { + visibleRangeProvider?.visibleSet ?? IndexSet() + } + + private var documentSet: IndexSet { + IndexSet(integersIn: visibleRangeProvider?.documentRange ?? .zero) + } + + /// Creates a new highlight provider state object. + /// Sends the `setUp` message to the highlight provider object. + /// - Parameters: + /// - delegate: The delegate for this provider. Is passed information about ranges to highlight. + /// - highlightProvider: The object to query for highlight information. + /// - textView: The text view to highlight, used by the highlight provider. + /// - visibleRangeProvider: A visible range provider for determining which ranges to query. + /// - language: The language to set up the provider with. + init( + delegate: HighlightProviderStateDelegate, + highlightProvider: HighlightProviding, + textView: TextView, + visibleRangeProvider: VisibleRangeProvider, + language: CodeLanguage + ) { + self.delegate = delegate + self.highlightProvider = highlightProvider + self.textView = textView + self.visibleRangeProvider = visibleRangeProvider + + highlightProvider.setUp(textView: textView, codeLanguage: language) + } + + func setLanguage(language: CodeLanguage) { + guard let textView else { return } + highlightProvider?.setUp(textView: textView, codeLanguage: language) + invalidate() + } + + /// Invalidates all pending and valid ranges, resetting the provider. + func invalidate() { + validSet.removeAll() + pendingSet.removeAll() + highlightInvalidRanges() + } +} + +private extension HighlightProviderState { + /// Invalidates a given range and adds it to the queue to be highlighted. + /// - Parameter range: The range to invalidate. + func invalidate(range: NSRange) { + let set = IndexSet(integersIn: range) + + if set.isEmpty { + return + } + + validSet.subtract(set) + + highlightInvalidRanges() + } + + /// Accumulates all pending ranges and calls `queryHighlights`. + func highlightInvalidRanges() { + var ranges: [NSRange] = [] + while let nextRange = getNextRange() { + ranges.append(nextRange) + } + queryHighlights(for: ranges) + } + + /// Gets the next `NSRange` to highlight based on the invalid set, visible set, and pending set. + /// - Returns: An `NSRange` to highlight if it could be fetched. + func getNextRange() -> NSRange? { + let set: IndexSet = documentSet // All text + .subtracting(validSet) // Subtract valid = Invalid set + .intersection(visibleSet) // Only visible indexes + .subtracting(pendingSet) // Don't include pending indexes + + guard let range = set.rangeView.first else { + return nil + } + + // Chunk the ranges in sets of rangeChunkLimit characters. + return NSRange( + location: range.lowerBound, + length: min(Self.rangeChunkLimit, range.upperBound - range.lowerBound) + ) + } + + /// Queries for highlights for the given ranges + /// - Parameter rangesToHighlight: The ranges to request highlights for. + func queryHighlights(for rangesToHighlight: [NSRange]) { + guard let textView else { return } + for range in rangesToHighlight { + highlightProvider?.queryHighlightsFor(textView: textView, range: range) { [weak self] result in + guard let providerId = self?.id else { return } + assert(Thread.isMainThread, "Highlighted ranges called on non-main thread.") + switch result { + case .success(let highlights): + self?.delegate?.applyHighlightResult( + provider: providerId, + highlights: highlights, + rangeToHighlight: range + ) + case .failure: + self?.invalidate(range: range) + } + self?.pendingSet.remove(integersIn: range) + } + } + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviding.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviding.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift index ffb2837fd..b1a3929fd 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift @@ -10,5 +10,5 @@ import Foundation /// This struct represents a range to highlight, as well as the capture name for syntax coloring. public struct HighlightRange: Sendable { let range: NSRange - let capture: CaptureName? + let capture: CaptureName } diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter+NSTextStorageDelegate.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter+NSTextStorageDelegate.swift deleted file mode 100644 index 096302641..000000000 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter+NSTextStorageDelegate.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Highlighter+NSTextStorageDelegate.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 1/18/24. -// - -import AppKit - -extension Highlighter: NSTextStorageDelegate { - /// Processes an edited range in the text. - /// Will query tree-sitter for any updated indices and re-highlight only the ranges that need it. - func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document - // each time an attribute is applied, we check to make sure this is in response to an edit. - guard editedMask.contains(.editedCharacters) else { return } - - self.storageDidEdit(editedRange: editedRange, delta: delta) - } - - func textStorage( - _ textStorage: NSTextStorage, - willProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - guard editedMask.contains(.editedCharacters) else { return } - - self.storageWillEdit(editedRange: editedRange) - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index d19b2689d..d84392397 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -12,10 +12,47 @@ import SwiftTreeSitter import CodeEditLanguages import OSLog +/* + +---------------------------------+ + | Highlighter | + | | + | - highlightProviders[] | + | - styledRangeContainer | + | | + | + refreshHighlightsIn(range:) | + +---------------------------------+ + | + | + v + +-------------------------------+ +-----------------------------+ + | RangeCaptureContainer | ------> | RangeStore | + | | | | + | - manages combined ranges | | - stores raw ranges & | + | - layers highlight styles | | captures | + | + getAttributesForRange() | +-----------------------------+ + +-------------------------------+ + ^ + | + | + +-------------------------------+ + | HighlightProviderState[] | (one for each provider) + | | + | - keeps valid/invalid ranges | + | - queries providers (async) | + | + updateStyledRanges() | + +-------------------------------+ + ^ + | + | + +-------------------------------+ + | HighlightProviding Object | (tree-sitter, LSP, spellcheck) + +-------------------------------+ + */ + /// The `Highlighter` class handles efficiently highlighting the `TextView` it's provided with. /// It will listen for text and visibility changes, and highlight syntax as needed. /// -/// One should rarely have to direcly modify or call methods on this class. Just keep it alive in +/// One should rarely have to directly modify or call methods on this class. Just keep it alive in /// memory and it will listen for bounds changes, text changes, etc. However, to completely invalidate all /// highlights use the ``invalidate()`` method to re-highlight all (visible) text, and the ``setLanguage`` /// method to update the highlighter with a new language if needed. @@ -23,95 +60,60 @@ import OSLog class Highlighter: NSObject { static private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "Highlighter") - // MARK: - Index Sets - - /// Any indexes that highlights have been requested for, but haven't been applied. - /// Indexes/ranges are added to this when highlights are requested and removed - /// after they are applied - private var pendingSet: IndexSet = .init() - - /// The set of valid indexes - private var validSet: IndexSet = .init() - - /// The set of visible indexes in tht text view - lazy private var visibleSet: IndexSet = { - return IndexSet(integersIn: textView?.visibleTextRange ?? NSRange()) - }() - - // MARK: - UI + /// The current language of the editor. + private var language: CodeLanguage /// The text view to highlight private weak var textView: TextView? - /// The editor theme - private var theme: EditorTheme - /// The object providing attributes for captures. private weak var attributeProvider: ThemeAttributesProviding? - /// The current language of the editor. - private var language: CodeLanguage + private var rangeContainer: StyledRangeContainer - /// Calculates invalidated ranges given an edit. - private(set) weak var highlightProvider: HighlightProviding? + private var providers: [HighlightProviderState] = [] - /// The length to chunk ranges into when passing to the highlighter. - private let rangeChunkLimit = 1024 + private var visibleRangeProvider: VisibleRangeProvider // MARK: - Init - /// Initializes the `Highlighter` - /// - Parameters: - /// - textView: The text view to highlight. - /// - treeSitterClient: The tree-sitter client to handle tree updates and highlight queries. - /// - theme: The theme to use for highlights. init( textView: TextView, - highlightProvider: HighlightProviding?, - theme: EditorTheme, + providers: [HighlightProviding], attributeProvider: ThemeAttributesProviding, language: CodeLanguage ) { + self.language = language self.textView = textView - self.highlightProvider = highlightProvider - self.theme = theme self.attributeProvider = attributeProvider - self.language = language + self.visibleRangeProvider = VisibleRangeProvider(textView: textView) + self.rangeContainer = StyledRangeContainer() super.init() - highlightProvider?.setUp(textView: textView, codeLanguage: language) - - if let scrollView = textView.enclosingScrollView { - NotificationCenter.default.addObserver( - self, - selector: #selector(visibleTextChanged(_:)), - name: NSView.frameDidChangeNotification, - object: scrollView - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(visibleTextChanged(_:)), - name: NSView.boundsDidChangeNotification, - object: scrollView.contentView + self.providers = providers.map { + HighlightProviderState( + delegate: rangeContainer, + highlightProvider: $0, + textView: textView, + visibleRangeProvider: visibleRangeProvider, + language: language ) } } // MARK: - Public - /// Invalidates all text in the textview. Useful for updating themes. + /// Invalidates all text in the editor. Useful for updating themes. public func invalidate() { - guard let textView else { return } - updateVisibleSet(textView: textView) - invalidate(range: textView.documentRange) + providers.forEach { $0.invalidate() } } /// Sets the language and causes a re-highlight of the entire text. /// - Parameter language: The language to update to. public func setLanguage(language: CodeLanguage) { guard let textView = self.textView else { return } + // Remove all current highlights. Makes the language setting feel snappier and tells the user we're doing // something immediately. textView.textStorage.setAttributes( @@ -119,57 +121,42 @@ class Highlighter: NSObject { range: NSRange(location: 0, length: textView.textStorage.length) ) textView.layoutManager.invalidateLayoutForRect(textView.visibleRect) - validSet.removeAll() - pendingSet.removeAll() - highlightProvider?.setUp(textView: textView, codeLanguage: language) - invalidate() - } - /// Sets the highlight provider. Will cause a re-highlight of the entire text. - /// - Parameter provider: The provider to use for future syntax highlights. - public func setHighlightProvider(_ provider: HighlightProviding) { - self.highlightProvider = provider - guard let textView = self.textView else { return } - highlightProvider?.setUp(textView: textView, codeLanguage: self.language) - invalidate() + providers.forEach { $0.setLanguage(language: language) } } deinit { - NotificationCenter.default.removeObserver(self) self.attributeProvider = nil self.textView = nil - self.highlightProvider = nil + self.providers = [] } } -// MARK: - Highlighting - -private extension Highlighter { - - /// Invalidates a given range and adds it to the queue to be highlighted. - /// - Parameter range: The range to invalidate. - func invalidate(range: NSRange) { - let set = IndexSet(integersIn: range) - - if set.isEmpty { - return - } - - validSet.subtract(set) +extension Highlighter: NSTextStorageDelegate { + /// Processes an edited range in the text. + /// Will query tree-sitter for any updated indices and re-highlight only the ranges that need it. + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document + // each time an attribute is applied, we check to make sure this is in response to an edit. + guard editedMask.contains(.editedCharacters) else { return } - highlightInvalidRanges() +// self.storageDidEdit(editedRange: editedRange, delta: delta) } - /// Begins highlighting any invalid ranges - func highlightInvalidRanges() { - // If there aren't any more ranges to highlight, don't do anything, otherwise continue highlighting - // any available ranges. - var rangesToQuery: [NSRange] = [] - while let range = getNextRange() { - rangesToQuery.append(range) - pendingSet.insert(range: range) - } + func textStorage( + _ textStorage: NSTextStorage, + willProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { return } +<<<<<<< Updated upstream queryHighlights(for: rangesToQuery) } @@ -328,5 +315,8 @@ extension Highlighter { func storageWillEdit(editedRange: NSRange) { guard let textView else { return } highlightProvider?.willApplyEdit(textView: textView, range: editedRange) +======= +// self.storageWillEdit(editedRange: editedRange) +>>>>>>> Stashed changes } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift deleted file mode 100644 index e4e8930e2..000000000 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighterTextView.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// HighlighterTextView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 1/26/23. -// - -import Foundation -import AppKit -import CodeEditTextView - -extension TextView { - public func stringForRange(_ nsRange: NSRange) -> String? { - textStorage.substring(from: nsRange) - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift new file mode 100644 index 000000000..de57b6fad --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift @@ -0,0 +1,250 @@ +// +// RangeStore+Node.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/16/24. +// + +extension RangeStore { + final class Node { + let order: Int + var keys: [KeyValue] + var children: [Node] + var maxContainingEndpoint: UInt32 + + var isLeaf: Bool { children.isEmpty } + + init(order: Int) { + self.order = order + self.keys = [] + self.children = [] + self.maxContainingEndpoint = 0 + + self.keys.reserveCapacity(order - 1) + self.children.reserveCapacity(order) + } + + func max() -> KeyValue? { + var node = self + while !node.isLeaf { + node = node.children[node.children.count - 1] + } + return node.keys.last + } + + func min() -> KeyValue? { + var node = self + while !node.isLeaf { + node = node.children[0] + } + return node.keys.first + } + + // MARK: - Insert + + @discardableResult + func insert(value: Element, range: Key) -> (promotedKey: KeyValue?, newNode: Node?) { + if isLeaf { + // Insert in order + let newKeyValue = KeyValue(key: range, value: value) + let insertionIndex = keys.firstIndex(where: { $0.key.lowerBound > range.lowerBound }) ?? keys.count + keys.insert(newKeyValue, at: insertionIndex) + + // Update maxContainingEndpoint going up + maxContainingEndpoint = Swift.max(maxContainingEndpoint, range.upperBound) + + // Check if the node is overfull and needs to be split + if keys.count >= keys.capacity { + return split() + } + } else { + // Find the correct child to insert into + let childIndex = keys.firstIndex(where: { $0.key.lowerBound > range.lowerBound }) ?? keys.count + let (promotedKey, newChild) = children[childIndex].insert(value: value, range: range) + + // If a child was split, insert the promoted key into the current node + if let promotedKey = promotedKey { + keys.insert(promotedKey, at: childIndex) + if let newChild = newChild { + children.insert(newChild, at: childIndex + 1) + } + + // Check if the node needs to be split + if keys.count >= keys.capacity { + return split() + } + } + } + + return (nil, nil) + } + + /// Split a node in half, returning the new node and the key to promote to the next level. + private func split() -> (promotedKey: KeyValue, newNode: Node) { + let middleIndex = keys.count / 2 + let promotedKey = keys[middleIndex] + + let newNode = Node(order: self.order) + newNode.keys.append(contentsOf: keys[(middleIndex + 1)...]) + keys.removeSubrange(middleIndex...) + + if !isLeaf { + newNode.children.append(contentsOf: children[(middleIndex + 1)...]) + children.removeSubrange((middleIndex + 1)...) + } + + newNode.maxContainingEndpoint = newNode.keys.map { $0.key.upperBound }.max() ?? 0 + self.maxContainingEndpoint = self.keys.map { $0.key.upperBound }.max() ?? 0 + + return (promotedKey, newNode) + } + + // MARK: - Delete + + /// Delete the given key from the tree. Assumes the key exists exactly + /// - Parameter range: The range to delete. + @discardableResult + func delete(range: Key) -> Bool { + if let keyIndex = keys.firstIndex(where: { $0.key == range }) { + if isLeaf { + keys.remove(at: keyIndex) + return true + } else { + deleteNonLeaf(keyIndex: keyIndex, range: range) + return true + } + } else if !isLeaf { + // Recursively delete from the appropriate child + let childIndex = keys.firstIndex(where: { $0.key.lowerBound > range.lowerBound }) ?? keys.count + let child = children[childIndex] + + // Ensure the child has enough keys to allow deletion + if child.keys.count < order { + fillChild(at: childIndex) + } + + // Recursive deletion + return children[childIndex].delete(range: range) + } + + // Key not found + return false + } + + /// Delete a key from a non-leaf node. Replacing the key with the next or last key in the tree. + private func deleteNonLeaf(keyIndex: Int, range: Key) { + // Non-leaf node: replace the key with a predecessor or successor + let predecessorNode = children[keyIndex] + let successorNode = children[keyIndex + 1] + + if predecessorNode.keys.count >= order { + if let predecessor = predecessorNode.max() { + keys[keyIndex] = predecessor + predecessorNode.delete(range: predecessor.key) + } + } else if successorNode.keys.count >= order { + if let successor = successorNode.min() { + keys[keyIndex] = successor + successorNode.delete(range: successor.key) + } + } else { + // Merge the key and two children, then delete recursively + mergeChild(at: keyIndex) + children[keyIndex].delete(range: range) + } + } + + /// Ensure the child meets the invariants of a B-Tree. + /// - Parameter index: The index of the child to update. + private func fillChild(at index: Int) { + if index > 0 && children[index - 1].keys.count > order - 1 { + borrowFromPrev(at: index) + } else if index < keys.count && children[index + 1].keys.count > order - 1 { + borrowFromNext(at: index) + } else { + // Merge with a sibling + if index > 0 { + mergeChild(at: index - 1) + } else { + mergeChild(at: index) + } + } + } + + /// Borrow a key from left sibling + private func borrowFromPrev(at index: Int) { + let child = children[index] + let leftSibling = children[index - 1] + child.keys.insert(keys[index - 1], at: 0) + keys[index - 1] = leftSibling.keys.removeLast() + + if !leftSibling.isLeaf { + child.children.insert(leftSibling.children.removeLast(), at: 0) + } + + child.maxContainingEndpoint = child.keys.map { $0.key.upperBound }.max() ?? 0 + leftSibling.maxContainingEndpoint = leftSibling.keys.map { $0.key.upperBound }.max() ?? 0 + } + + /// Borrow a key from the right sibling + private func borrowFromNext(at index: Int) { + let child = children[index] + let rightSibling = children[index + 1] + child.keys.append(keys[index]) + keys[index] = rightSibling.keys.removeFirst() + + if !rightSibling.isLeaf { + child.children.append(rightSibling.children.removeFirst()) + } + + child.maxContainingEndpoint = child.keys.map { $0.key.upperBound }.max() ?? 0 + rightSibling.maxContainingEndpoint = rightSibling.keys.map { $0.key.upperBound }.max() ?? 0 + } + + /// Merge the child at 'index' with the next sibling + private func mergeChild(at index: Int) { + let child = children[index] + let sibling = children[index + 1] + child.keys.append(keys.remove(at: index)) + child.keys.append(contentsOf: sibling.keys) + if !sibling.isLeaf { + child.children.append(contentsOf: sibling.children) + } + + children.remove(at: index + 1) + child.maxContainingEndpoint = child.keys.map { $0.key.upperBound }.max() ?? 0 + } + + // MARK: - Search + + /// Searches the node and it's children for any overlapping ranges. + /// - Parameter range: The range to query + /// - Returns: All (key,value) pairs overlapping the given key. + func findRanges(overlapping range: Key) -> [KeyValue] { + var overlappingRanges: [KeyValue] = [] + + var idx = 0 + while idx < keys.count && keys[idx].key.lowerBound < range.upperBound { + idx += 1 + + if keys[idx - 1].key.overlaps(range) { + overlappingRanges.append(keys[idx - 1]) + } + } + + if !isLeaf { + for childIdx in 0..= range.lowerBound { + overlappingRanges.append(contentsOf: children[childIdx].findRanges(overlapping: range)) + } + + if idx < children.count { + if children[idx].maxContainingEndpoint >= range.lowerBound { + overlappingRanges.append(contentsOf: children[idx].findRanges(overlapping: range)) + } + } + } + + return overlappingRanges + } + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift new file mode 100644 index 000000000..03567690f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift @@ -0,0 +1,78 @@ +// +// RangeStore.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/16/24. +// + +import Foundation +import OrderedCollections + +fileprivate extension Range { + static func from(_ other: Range) -> Range { + return UInt32(other.startIndex).. { + return Int(startIndex)..) -> Bool { + other.startIndex >= startIndex && other.endIndex <= endIndex + } + + func subtract(_ other: Range) -> Range { + assert(!strictContains(other), "Subtract cannot act on a range that is larger than the given range") + if startIndex < other.startIndex { + return startIndex.. { + /// Using UInt32 as we can halve the memory use of keys in the tree for the small cost of converting them + /// in public calls. + typealias Key = Range + + struct KeyValue { + let key: Key + let value: Element + } + + private let order: Int + private var root: Node + + init(order: Int = 4) { + self.order = order + self.root = Node(order: self.order) + } + + func insert(value: Element, range: Range) { + let key = Key.from(range) + root.insert(value: value, range: key) + } + + @discardableResult + func delete(range: Range) -> Bool { + let key = Key.from(range) + return root.delete(range: key) + } + +// func deleteRanges(overlapping range: Range) { +// let key = Key.from(range) +// let keyPairs = ranges(overlapping: range) +// for pair in keyPairs { +// root.delete(range: pair.key) +// if !key.strictContains(pair.key) { +// root.insert(value: pair.value, range: key.) +// } +// } +// } + + func ranges(overlapping range: Range) -> [KeyValue] { + let key = Key.from(range) + return root.findRanges(overlapping: key) + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift new file mode 100644 index 000000000..cd22a1a03 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -0,0 +1,18 @@ +// +// StyledRangeContainer.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/13/24. +// + +import Foundation + +class StyledRangeContainer { + // TODO: Styled Range Container +} + +extension StyledRangeContainer: HighlightProviderStateDelegate { + func applyHighlightResult(provider: UUID, highlights: [HighlightRange], rangeToHighlight: NSRange) { + // TODO: Apply Result + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift new file mode 100644 index 000000000..abcb6d951 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift @@ -0,0 +1,77 @@ +// +// VisibleRangeProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/13/24. +// + +import AppKit +import CodeEditTextView + +class VisibleRangeProvider { + private weak var textView: TextView? + + var documentRange: NSRange { + textView?.documentRange ?? .notFound + } + + /// The set of visible indexes in the text view + lazy var visibleSet: IndexSet = { + return IndexSet(integersIn: textView?.visibleTextRange ?? NSRange()) + }() + + init(textView: TextView) { + self.textView = textView + + if let scrollView = textView.enclosingScrollView { + NotificationCenter.default.addObserver( + self, + selector: #selector(visibleTextChanged(_:)), + name: NSView.frameDidChangeNotification, + object: scrollView + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(visibleTextChanged(_:)), + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView + ) + } else { + NotificationCenter.default.addObserver( + self, + selector: #selector(visibleTextChanged(_:)), + name: NSView.frameDidChangeNotification, + object: textView + ) + } + } + + private func updateVisibleSet(textView: TextView) { + if let newVisibleRange = textView.visibleTextRange { + visibleSet = IndexSet(integersIn: newVisibleRange) + } + } + + /// Updates the view to highlight newly visible text when the textview is scrolled or bounds change. + @objc func visibleTextChanged(_ notification: Notification) { + let textView: TextView + if let clipView = notification.object as? NSClipView, + let documentView = clipView.enclosingScrollView?.documentView as? TextView { + textView = documentView + } else if let scrollView = notification.object as? NSScrollView, + let documentView = scrollView.documentView as? TextView { + textView = documentView + } else if let documentView = notification.object as? TextView { + textView = documentView + } else { + return + } + + updateVisibleSet(textView: textView) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/Tests/CodeEditSourceEditorTests/HighlighterTests.swift b/Tests/CodeEditSourceEditorTests/HighlighterTests.swift index 3998bfe72..f255f4d31 100644 --- a/Tests/CodeEditSourceEditorTests/HighlighterTests.swift +++ b/Tests/CodeEditSourceEditorTests/HighlighterTests.swift @@ -49,7 +49,6 @@ final class HighlighterTests: XCTestCase { let highlighter = Mock.highlighter( textView: textView, highlightProvider: highlightProvider, - theme: theme, attributeProvider: attributeProvider ) diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift new file mode 100644 index 000000000..4d0ad0dff --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import CodeEditSourceEditor + +class RangeStoreBenchmarkTests: XCTestCase { + var rng = RandomNumberGeneratorWithSeed(seed: 942) + + struct RandomNumberGeneratorWithSeed: RandomNumberGenerator { + init(seed: Int) { srand48(seed) } + func next() -> UInt64 { return UInt64(drand48() * Double(UInt64.max)) } // swiftlint:disable:this legacy_random + } + + func test_benchmarkInsert() { + let rangeStore = RangeStore() + let numberOfInserts = 100_000 + var ranges = (0..() + let numberOfInserts = 100_000 + var ranges = (0..() + let numberOfInserts = 100_000 + var ranges = (0..! + + override func setUp() { + super.setUp() + rangeStore = RangeStore() + } + + func test_insertRange() { + let range1 = 0..<5 + let range2 = 5..<10 + let range3 = 10..<15 + + rangeStore.insert(value: "Value 1", range: range1) + rangeStore.insert(value: "Value 2", range: range2) + rangeStore.insert(value: "Value 3", range: range3) + + // Validate that the inserted ranges are present + let results = rangeStore.ranges(overlapping: 0..<20) + XCTAssertEqual(results.count, 3) + XCTAssertEqual(results[0].value, "Value 1") + XCTAssertEqual(results[1].value, "Value 2") + XCTAssertEqual(results[2].value, "Value 3") + } + + func test_deleteRange() { + let range1 = 0..<5 + let range2 = 5..<10 + + rangeStore.insert(value: "Value 1", range: range1) + rangeStore.insert(value: "Value 2", range: range2) + + // Delete range2 + XCTAssertTrue(rangeStore.delete(range: range2)) + + // Validate that range2 is deleted + let resultsAfterDelete = rangeStore.ranges(overlapping: 0..<20) + XCTAssertEqual(resultsAfterDelete.count, 1) + XCTAssertEqual(resultsAfterDelete[0].value, "Value 1") + } + + func test_searchRange() { + let range1 = 0..<5 + let range2 = 5..<10 + + rangeStore.insert(value: "Value 1", range: range1) + rangeStore.insert(value: "Value 2", range: range2) + + // Search for a specific range + let searchResults = rangeStore.ranges(overlapping: 5..<6) + XCTAssertEqual(searchResults.count, 1) + XCTAssertEqual(searchResults[0].value, "Value 2") + } + + func test_searchEmptyTree() { + // Search in an empty tree + let searchResults = rangeStore.ranges(overlapping: 0..<5) + XCTAssertTrue(searchResults.isEmpty) + } + + func test_deleteNonExistentRange() { + let range = 0..<5 + // Attempt to delete a range that doesn't exist + XCTAssertFalse(rangeStore.delete(range: range)) + } +} diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift new file mode 100644 index 000000000..211acd6fc --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import CodeEditSourceEditor + +final class VisibleRangeProviderTests: XCTestCase { + func test_updateOnScroll() { + let (scrollView, textView) = Mock.scrollingTextView() + textView.string = Array(repeating: "\n", count: 400).joined() + textView.layout() + + let rangeProvider = VisibleRangeProvider(textView: textView) + let originalSet = rangeProvider.visibleSet + + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 250)) + + scrollView.layoutSubtreeIfNeeded() + textView.layout() + + XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) + } + + func test_updateOnResize() { + let (scrollView, textView) = Mock.scrollingTextView() + textView.string = Array(repeating: "\n", count: 400).joined() + textView.layout() + + let rangeProvider = VisibleRangeProvider(textView: textView) + let originalSet = rangeProvider.visibleSet + + scrollView.setFrameSize(NSSize(width: 250, height: 450)) + + scrollView.layoutSubtreeIfNeeded() + textView.layout() + + XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) + } + + // Skipping due to a bug in the textview that returns all indices for the visible rect + // when not in a scroll view + + func _test_updateOnResizeNoScrollView() { + let textView = Mock.textView() + textView.frame = NSRect(x: 0, y: 0, width: 100, height: 100) + textView.string = Array(repeating: "\n", count: 400).joined() + textView.layout() + + let rangeProvider = VisibleRangeProvider(textView: textView) + let originalSet = rangeProvider.visibleSet + + textView.setFrameSize(NSSize(width: 350, height: 450)) + + textView.layout() + + XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) + } +} diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 7d1475396..e1c92bd7f 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit import CodeEditTextView import CodeEditLanguages @testable import CodeEditSourceEditor @@ -64,6 +65,17 @@ enum Mock { ) } + static func scrollingTextView() -> (NSScrollView, TextView) { + let scrollView = NSScrollView(frame: .init(x: 0, y: 0, width: 250, height: 250)) + scrollView.contentView.postsBoundsChangedNotifications = true + scrollView.postsFrameChangedNotifications = true + let textView = textView() + scrollView.documentView = textView + scrollView.layoutSubtreeIfNeeded() + textView.layout() + return (scrollView, textView) + } + static func treeSitterClient(forceSync: Bool = false) -> TreeSitterClient { let client = TreeSitterClient() client.forceSyncOperation = forceSync @@ -74,14 +86,12 @@ enum Mock { static func highlighter( textView: TextView, highlightProvider: HighlightProviding, - theme: EditorTheme, attributeProvider: ThemeAttributesProviding, language: CodeLanguage = .default ) -> Highlighter { Highlighter( textView: textView, - highlightProvider: highlightProvider, - theme: theme, + providers: [highlightProvider], attributeProvider: attributeProvider, language: language ) From 6214f2dbeb6ed424c8bdb2250cc4d1c8c456b036 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Oct 2024 13:01:40 -0500 Subject: [PATCH 02/24] I've Since Realized This Is Incorrect --- .../Highlighting/Highlighter.swift | 162 ------------------ .../RangeStore+Node.swift | 14 +- .../StyledRangeContainer/RangeStore.swift | 102 ++++++++--- .../Highlighting/RangeStoreBenchmarks.swift | 1 + .../Highlighting/RangeStoreTests.swift | 36 ++-- 5 files changed, 111 insertions(+), 204 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index d84392397..1aff62870 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -155,168 +155,6 @@ extension Highlighter: NSTextStorageDelegate { changeInLength delta: Int ) { guard editedMask.contains(.editedCharacters) else { return } - -<<<<<<< Updated upstream - queryHighlights(for: rangesToQuery) - } - - /// Highlights the given ranges - /// - Parameter ranges: The ranges to request highlights for. - func queryHighlights(for rangesToHighlight: [NSRange]) { - guard let textView else { return } - - DispatchQueue.dispatchMainIfNot { - for range in rangesToHighlight { - self.highlightProvider?.queryHighlightsFor(textView: textView, range: range) { [weak self] highlights in - assert(Thread.isMainThread, "Highlighted ranges called on non-main thread.") - self?.applyHighlightResult(highlights, rangeToHighlight: range) - } - } - } - } - - /// Applies a highlight query result to the text view. - /// - Parameters: - /// - results: The result of a highlight query. - /// - rangeToHighlight: The range to apply the highlight to. - private func applyHighlightResult(_ results: Result<[HighlightRange], Error>, rangeToHighlight: NSRange) { - pendingSet.remove(integersIn: rangeToHighlight) - - switch results { - case let .failure(error): - if case HighlightProvidingError.operationCancelled = error { - invalidate(range: rangeToHighlight) - } else { - Self.logger.error("Failed to query highlight range: \(error)") - } - case let .success(results): - guard let attributeProvider = self.attributeProvider, - visibleSet.intersects(integersIn: rangeToHighlight) else { - return - } - validSet.formUnion(IndexSet(integersIn: rangeToHighlight)) - - // Loop through each highlight and modify the textStorage accordingly. - textView?.textStorage.beginEditing() - - // Create a set of indexes that were not highlighted. - var ignoredIndexes = IndexSet(integersIn: rangeToHighlight) - - // Apply all highlights that need color - for highlight in results - where textView?.documentRange.upperBound ?? 0 > highlight.range.upperBound { - textView?.textStorage.setAttributes( - attributeProvider.attributesFor(highlight.capture), - range: highlight.range - ) - - // Remove highlighted indexes from the "ignored" indexes. - ignoredIndexes.remove(integersIn: highlight.range) - } - - // For any indices left over, we need to apply normal attributes to them - // This fixes the case where characters are changed to have a non-text color, and then are skipped when - // they need to be changed back. - for ignoredRange in ignoredIndexes.rangeView - where textView?.documentRange.upperBound ?? 0 > ignoredRange.upperBound { - textView?.textStorage.setAttributes(attributeProvider.attributesFor(nil), range: NSRange(ignoredRange)) - } - - textView?.textStorage.endEditing() - textView?.layoutManager.invalidateLayoutForRange(rangeToHighlight) - } - } - - /// Gets the next `NSRange` to highlight based on the invalid set, visible set, and pending set. - /// - Returns: An `NSRange` to highlight if it could be fetched. - func getNextRange() -> NSRange? { - let set: IndexSet = IndexSet(integersIn: textView?.documentRange ?? .zero) // All text - .subtracting(validSet) // Subtract valid = Invalid set - .intersection(visibleSet) // Only visible indexes - .subtracting(pendingSet) // Don't include pending indexes - - guard let range = set.rangeView.first else { - return nil - } - - // Chunk the ranges in sets of rangeChunkLimit characters. - return NSRange( - location: range.lowerBound, - length: min(rangeChunkLimit, range.upperBound - range.lowerBound) - ) - } -} - -// MARK: - Visible Content Updates - -private extension Highlighter { - private func updateVisibleSet(textView: TextView) { - if let newVisibleRange = textView.visibleTextRange { - visibleSet = IndexSet(integersIn: newVisibleRange) - } - } - - /// Updates the view to highlight newly visible text when the textview is scrolled or bounds change. - @objc func visibleTextChanged(_ notification: Notification) { - let textView: TextView - if let clipView = notification.object as? NSClipView, - let documentView = clipView.enclosingScrollView?.documentView as? TextView { - textView = documentView - } else if let scrollView = notification.object as? NSScrollView, - let documentView = scrollView.documentView as? TextView { - textView = documentView - } else { - return - } - - updateVisibleSet(textView: textView) - - // Any indices that are both *not* valid and in the visible text range should be invalidated - let newlyInvalidSet = visibleSet.subtracting(validSet) - - for range in newlyInvalidSet.rangeView.map({ NSRange($0) }) { - invalidate(range: range) - } - } -} - -// MARK: - Editing - -extension Highlighter { - func storageDidEdit(editedRange: NSRange, delta: Int) { - guard let textView else { return } - - let range = NSRange(location: editedRange.location, length: editedRange.length - delta) - if delta > 0 { - visibleSet.insert(range: editedRange) - } - - updateVisibleSet(textView: textView) - - highlightProvider?.applyEdit(textView: textView, range: range, delta: delta) { [weak self] result in - switch result { - case let .success(invalidIndexSet): - let indexSet = invalidIndexSet.union(IndexSet(integersIn: editedRange)) - - for range in indexSet.rangeView { - self?.invalidate(range: NSRange(range)) - } - case let .failure(error): - if case HighlightProvidingError.operationCancelled = error { - self?.invalidate(range: range) - return - } else { - Self.logger.error("Failed to apply edit. Query returned with error: \(error)") - } - } - } - } - - func storageWillEdit(editedRange: NSRange) { - guard let textView else { return } - highlightProvider?.willApplyEdit(textView: textView, range: editedRange) -======= // self.storageWillEdit(editedRange: editedRange) ->>>>>>> Stashed changes } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift index de57b6fad..565155c6e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift @@ -7,12 +7,12 @@ extension RangeStore { final class Node { - let order: Int - var keys: [KeyValue] - var children: [Node] - var maxContainingEndpoint: UInt32 + private let order: Int + private var keys: [KeyValue] + private var children: [Node] + private var maxContainingEndpoint: UInt32 - var isLeaf: Bool { children.isEmpty } + private var isLeaf: Bool { children.isEmpty } init(order: Int) { self.order = order @@ -24,7 +24,7 @@ extension RangeStore { self.children.reserveCapacity(order) } - func max() -> KeyValue? { + private func max() -> KeyValue? { var node = self while !node.isLeaf { node = node.children[node.children.count - 1] @@ -32,7 +32,7 @@ extension RangeStore { return node.keys.last } - func min() -> KeyValue? { + private func min() -> KeyValue? { var node = self while !node.isLeaf { node = node.children[0] diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift index 03567690f..abd97cfd7 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift @@ -16,21 +16,30 @@ fileprivate extension Range { var generic: Range { return Int(startIndex)..) -> Bool { other.startIndex >= startIndex && other.endIndex <= endIndex } - - func subtract(_ other: Range) -> Range { - assert(!strictContains(other), "Subtract cannot act on a range that is larger than the given range") - if startIndex < other.startIndex { - return startIndex..` to optimize memory usage, and are converted from `Range` +/// when interacting with public +/// +/// ```swift +/// let store = RangeStore() +/// store.insert(value: "A", range: 1..<5) +/// store.delete(overlapping: 3..<4) // Clears part of a range. +/// let results = store.ranges(overlapping: 1..<6) +/// ``` + package final class RangeStore { /// Using UInt32 as we can halve the memory use of keys in the tree for the small cost of converting them /// in public calls. @@ -44,35 +53,84 @@ package final class RangeStore { private let order: Int private var root: Node + /// Initialize the store. + /// - Parameter order: The order of the internal B-tree. Defaults to `4`. init(order: Int = 4) { self.order = order self.root = Node(order: self.order) } + /// Insert a key-value pair into the store. + /// - Parameters: + /// - value: The value to insert. + /// - range: The range to insert the value at. func insert(value: Element, range: Range) { let key = Key.from(range) root.insert(value: value, range: key) } + /// Delete a range from the store. + /// The range must match exactly with a range in the store, or it will not be deleted. + /// See ``delete(overlapping:)`` for deleting unknown ranges. + /// - Parameter range: The range to remove. + /// - Returns: Whether or not a value was removed from the store. @discardableResult func delete(range: Range) -> Bool { let key = Key.from(range) return root.delete(range: key) } -// func deleteRanges(overlapping range: Range) { -// let key = Key.from(range) -// let keyPairs = ranges(overlapping: range) -// for pair in keyPairs { -// root.delete(range: pair.key) -// if !key.strictContains(pair.key) { -// root.insert(value: pair.value, range: key.) -// } -// } -// } + /// Clears a range and all associated values. + /// + /// This is different from `delete`, which deletes a single already-known range from the store. This method removes + /// a range entirely, trimming ranges to effectively clear a range of values. + /// + /// ``` + /// 1 2 3 4 5 6 # Indices + /// |-----| |-----| # Stored Ranges + /// + /// - Call `delete` 3..<5 + /// + /// 1 2 3 4 5 6 # Indices + /// |--| |--| # Stored Ranges + /// ``` + /// + /// - Complexity: `O(n)` worst case, `O(m log n)` for small ranges where `m` is the number of results returned. + /// - Parameter range: The range to clear. + func delete(overlapping range: Range) { + let key = Key.from(range) + let keySet = IndexSet(integersIn: key.range) - func ranges(overlapping range: Range) -> [KeyValue] { + let keyPairs = root.findRanges(overlapping: key) + for pair in keyPairs { + root.delete(range: pair.key) + + // Re-Insert any ranges that overlapped with the key but weren't encapsulated. + if !key.strictContains(pair.key) { + let remainingSet = IndexSet(integersIn: pair.key.range).subtracting(keySet) + for range in remainingSet.rangeView { + let newKey = Key.from(range) + root.insert(value: pair.value, range: newKey) + } + } + } + } + + /// Search for all ranges overlapping the given range. + /// ``` + /// 1 2 3 4 5 6 # Indices + /// |-----| |-----| # Stored Ranges + /// + /// - Call `ranges(overlapping:)` 1..<5 + /// - Returns: [1..<4, 4..<7] + /// ``` + /// - Complexity: `O(n)` worst case, `O(m log n)` for small ranges where `m` is the number of results returned. + /// - Parameter range: The range to search. + /// - Returns: All key-value pairs that overlap the given range. + func ranges(overlapping range: Range) -> [(key: Range, value: Element)] { let key = Key.from(range) - return root.findRanges(overlapping: key) + return root.findRanges(overlapping: key).map { keyValue in + (keyValue.key.generic, keyValue.value) + } } } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift index 4d0ad0dff..c7c27a5c0 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift @@ -4,6 +4,7 @@ import XCTest class RangeStoreBenchmarkTests: XCTestCase { var rng = RandomNumberGeneratorWithSeed(seed: 942) + // to keep these stable struct RandomNumberGeneratorWithSeed: RandomNumberGenerator { init(seed: Int) { srand48(seed) } func next() -> UInt64 { return UInt64(drand48() * Double(UInt64.max)) } // swiftlint:disable:this legacy_random diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreTests.swift index 20382cee5..fee04a3b6 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreTests.swift @@ -2,14 +2,8 @@ import XCTest @testable import CodeEditSourceEditor class RangeStoreTests: XCTestCase { - var rangeStore: RangeStore! - - override func setUp() { - super.setUp() - rangeStore = RangeStore() - } - func test_insertRange() { + let rangeStore = RangeStore() let range1 = 0..<5 let range2 = 5..<10 let range3 = 10..<15 @@ -18,7 +12,6 @@ class RangeStoreTests: XCTestCase { rangeStore.insert(value: "Value 2", range: range2) rangeStore.insert(value: "Value 3", range: range3) - // Validate that the inserted ranges are present let results = rangeStore.ranges(overlapping: 0..<20) XCTAssertEqual(results.count, 3) XCTAssertEqual(results[0].value, "Value 1") @@ -27,43 +20,60 @@ class RangeStoreTests: XCTestCase { } func test_deleteRange() { + let rangeStore = RangeStore() let range1 = 0..<5 let range2 = 5..<10 rangeStore.insert(value: "Value 1", range: range1) rangeStore.insert(value: "Value 2", range: range2) - // Delete range2 XCTAssertTrue(rangeStore.delete(range: range2)) - // Validate that range2 is deleted let resultsAfterDelete = rangeStore.ranges(overlapping: 0..<20) XCTAssertEqual(resultsAfterDelete.count, 1) XCTAssertEqual(resultsAfterDelete[0].value, "Value 1") } + func test_insertMultipleRangesThenDelete() { + let rangeStore = RangeStore() + let range1 = 0..<5 + let range2 = 5..<10 + let range3 = 10..<15 + + rangeStore.insert(value: "Value 1", range: range1) + rangeStore.insert(value: "Value 2", range: range2) + rangeStore.insert(value: "Value 3", range: range3) + + XCTAssertTrue(rangeStore.delete(range: range1)) + XCTAssertTrue(rangeStore.delete(range: range2)) + XCTAssertTrue(rangeStore.delete(range: range3)) + + let results = rangeStore.ranges(overlapping: 0..<20) + XCTAssertTrue(results.isEmpty) + } + func test_searchRange() { + let rangeStore = RangeStore() let range1 = 0..<5 let range2 = 5..<10 rangeStore.insert(value: "Value 1", range: range1) rangeStore.insert(value: "Value 2", range: range2) - // Search for a specific range let searchResults = rangeStore.ranges(overlapping: 5..<6) XCTAssertEqual(searchResults.count, 1) XCTAssertEqual(searchResults[0].value, "Value 2") } func test_searchEmptyTree() { - // Search in an empty tree + let rangeStore = RangeStore() let searchResults = rangeStore.ranges(overlapping: 0..<5) XCTAssertTrue(searchResults.isEmpty) } func test_deleteNonExistentRange() { + let rangeStore = RangeStore() let range = 0..<5 - // Attempt to delete a range that doesn't exist XCTAssertFalse(rangeStore.delete(range: range)) } } From f313e7c4fcebf38d9ae37ffc65c48a1b2ca0ea25 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Oct 2024 15:35:31 -0500 Subject: [PATCH 03/24] Finalize `StyledRangeStore` and Tests --- .../xcschemes/CodeEditSourceEditor.xcscheme | 79 ++++++ .../Enums/CaptureModifiers.swift | 10 + .../Extensions/Range+Length.swift | 16 ++ .../RangeStore+Node.swift | 250 ------------------ .../StyledRangeContainer/RangeStore.swift | 136 ---------- .../StyledRangeContainer.swift | 8 +- .../StyledRangeStore+Internals.swift | 54 ++++ .../StyledRangeStore+OffsetMetric.swift | 22 ++ .../StyledRangeStore+StyledRun.swift | 93 +++++++ .../StyledRangeStore/StyledRangeStore.swift | 92 +++++++ .../Highlighting/RangeStoreBenchmarks.swift | 61 ----- .../Highlighting/RangeStoreTests.swift | 79 ------ .../Highlighting/StyledRangeStoreTests.swift | 228 ++++++++++++++++ 13 files changed, 600 insertions(+), 528 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme create mode 100644 Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift create mode 100644 Sources/CodeEditSourceEditor/Extensions/Range+Length.swift delete mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift delete mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+OffsetMetric.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift delete mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift delete mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreTests.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme new file mode 100644 index 000000000..96f701304 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift new file mode 100644 index 000000000..f363cc571 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift @@ -0,0 +1,10 @@ +// +// CaptureModifiers.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/24/24. +// + +enum CaptureModifiers: String, CaseIterable, Sendable { + case builtin +} diff --git a/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift b/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift new file mode 100644 index 000000000..8e20d24e1 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift @@ -0,0 +1,16 @@ +// +// Range+Length.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/25/24. +// + +import Foundation + +extension Range where Bound == Int { + var length: Bound { upperBound - lowerBound } + + init(lowerBound: Int, length: Int) { + self = lowerBound..<(lowerBound + length) + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift deleted file mode 100644 index 565155c6e..000000000 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore+Node.swift +++ /dev/null @@ -1,250 +0,0 @@ -// -// RangeStore+Node.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 10/16/24. -// - -extension RangeStore { - final class Node { - private let order: Int - private var keys: [KeyValue] - private var children: [Node] - private var maxContainingEndpoint: UInt32 - - private var isLeaf: Bool { children.isEmpty } - - init(order: Int) { - self.order = order - self.keys = [] - self.children = [] - self.maxContainingEndpoint = 0 - - self.keys.reserveCapacity(order - 1) - self.children.reserveCapacity(order) - } - - private func max() -> KeyValue? { - var node = self - while !node.isLeaf { - node = node.children[node.children.count - 1] - } - return node.keys.last - } - - private func min() -> KeyValue? { - var node = self - while !node.isLeaf { - node = node.children[0] - } - return node.keys.first - } - - // MARK: - Insert - - @discardableResult - func insert(value: Element, range: Key) -> (promotedKey: KeyValue?, newNode: Node?) { - if isLeaf { - // Insert in order - let newKeyValue = KeyValue(key: range, value: value) - let insertionIndex = keys.firstIndex(where: { $0.key.lowerBound > range.lowerBound }) ?? keys.count - keys.insert(newKeyValue, at: insertionIndex) - - // Update maxContainingEndpoint going up - maxContainingEndpoint = Swift.max(maxContainingEndpoint, range.upperBound) - - // Check if the node is overfull and needs to be split - if keys.count >= keys.capacity { - return split() - } - } else { - // Find the correct child to insert into - let childIndex = keys.firstIndex(where: { $0.key.lowerBound > range.lowerBound }) ?? keys.count - let (promotedKey, newChild) = children[childIndex].insert(value: value, range: range) - - // If a child was split, insert the promoted key into the current node - if let promotedKey = promotedKey { - keys.insert(promotedKey, at: childIndex) - if let newChild = newChild { - children.insert(newChild, at: childIndex + 1) - } - - // Check if the node needs to be split - if keys.count >= keys.capacity { - return split() - } - } - } - - return (nil, nil) - } - - /// Split a node in half, returning the new node and the key to promote to the next level. - private func split() -> (promotedKey: KeyValue, newNode: Node) { - let middleIndex = keys.count / 2 - let promotedKey = keys[middleIndex] - - let newNode = Node(order: self.order) - newNode.keys.append(contentsOf: keys[(middleIndex + 1)...]) - keys.removeSubrange(middleIndex...) - - if !isLeaf { - newNode.children.append(contentsOf: children[(middleIndex + 1)...]) - children.removeSubrange((middleIndex + 1)...) - } - - newNode.maxContainingEndpoint = newNode.keys.map { $0.key.upperBound }.max() ?? 0 - self.maxContainingEndpoint = self.keys.map { $0.key.upperBound }.max() ?? 0 - - return (promotedKey, newNode) - } - - // MARK: - Delete - - /// Delete the given key from the tree. Assumes the key exists exactly - /// - Parameter range: The range to delete. - @discardableResult - func delete(range: Key) -> Bool { - if let keyIndex = keys.firstIndex(where: { $0.key == range }) { - if isLeaf { - keys.remove(at: keyIndex) - return true - } else { - deleteNonLeaf(keyIndex: keyIndex, range: range) - return true - } - } else if !isLeaf { - // Recursively delete from the appropriate child - let childIndex = keys.firstIndex(where: { $0.key.lowerBound > range.lowerBound }) ?? keys.count - let child = children[childIndex] - - // Ensure the child has enough keys to allow deletion - if child.keys.count < order { - fillChild(at: childIndex) - } - - // Recursive deletion - return children[childIndex].delete(range: range) - } - - // Key not found - return false - } - - /// Delete a key from a non-leaf node. Replacing the key with the next or last key in the tree. - private func deleteNonLeaf(keyIndex: Int, range: Key) { - // Non-leaf node: replace the key with a predecessor or successor - let predecessorNode = children[keyIndex] - let successorNode = children[keyIndex + 1] - - if predecessorNode.keys.count >= order { - if let predecessor = predecessorNode.max() { - keys[keyIndex] = predecessor - predecessorNode.delete(range: predecessor.key) - } - } else if successorNode.keys.count >= order { - if let successor = successorNode.min() { - keys[keyIndex] = successor - successorNode.delete(range: successor.key) - } - } else { - // Merge the key and two children, then delete recursively - mergeChild(at: keyIndex) - children[keyIndex].delete(range: range) - } - } - - /// Ensure the child meets the invariants of a B-Tree. - /// - Parameter index: The index of the child to update. - private func fillChild(at index: Int) { - if index > 0 && children[index - 1].keys.count > order - 1 { - borrowFromPrev(at: index) - } else if index < keys.count && children[index + 1].keys.count > order - 1 { - borrowFromNext(at: index) - } else { - // Merge with a sibling - if index > 0 { - mergeChild(at: index - 1) - } else { - mergeChild(at: index) - } - } - } - - /// Borrow a key from left sibling - private func borrowFromPrev(at index: Int) { - let child = children[index] - let leftSibling = children[index - 1] - child.keys.insert(keys[index - 1], at: 0) - keys[index - 1] = leftSibling.keys.removeLast() - - if !leftSibling.isLeaf { - child.children.insert(leftSibling.children.removeLast(), at: 0) - } - - child.maxContainingEndpoint = child.keys.map { $0.key.upperBound }.max() ?? 0 - leftSibling.maxContainingEndpoint = leftSibling.keys.map { $0.key.upperBound }.max() ?? 0 - } - - /// Borrow a key from the right sibling - private func borrowFromNext(at index: Int) { - let child = children[index] - let rightSibling = children[index + 1] - child.keys.append(keys[index]) - keys[index] = rightSibling.keys.removeFirst() - - if !rightSibling.isLeaf { - child.children.append(rightSibling.children.removeFirst()) - } - - child.maxContainingEndpoint = child.keys.map { $0.key.upperBound }.max() ?? 0 - rightSibling.maxContainingEndpoint = rightSibling.keys.map { $0.key.upperBound }.max() ?? 0 - } - - /// Merge the child at 'index' with the next sibling - private func mergeChild(at index: Int) { - let child = children[index] - let sibling = children[index + 1] - child.keys.append(keys.remove(at: index)) - child.keys.append(contentsOf: sibling.keys) - if !sibling.isLeaf { - child.children.append(contentsOf: sibling.children) - } - - children.remove(at: index + 1) - child.maxContainingEndpoint = child.keys.map { $0.key.upperBound }.max() ?? 0 - } - - // MARK: - Search - - /// Searches the node and it's children for any overlapping ranges. - /// - Parameter range: The range to query - /// - Returns: All (key,value) pairs overlapping the given key. - func findRanges(overlapping range: Key) -> [KeyValue] { - var overlappingRanges: [KeyValue] = [] - - var idx = 0 - while idx < keys.count && keys[idx].key.lowerBound < range.upperBound { - idx += 1 - - if keys[idx - 1].key.overlaps(range) { - overlappingRanges.append(keys[idx - 1]) - } - } - - if !isLeaf { - for childIdx in 0..= range.lowerBound { - overlappingRanges.append(contentsOf: children[childIdx].findRanges(overlapping: range)) - } - - if idx < children.count { - if children[idx].maxContainingEndpoint >= range.lowerBound { - overlappingRanges.append(contentsOf: children[idx].findRanges(overlapping: range)) - } - } - } - - return overlappingRanges - } - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift deleted file mode 100644 index abd97cfd7..000000000 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/RangeStore.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// RangeStore.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 10/16/24. -// - -import Foundation -import OrderedCollections - -fileprivate extension Range { - static func from(_ other: Range) -> Range { - return UInt32(other.startIndex).. { - return Int(startIndex)..) -> Bool { - other.startIndex >= startIndex && other.endIndex <= endIndex - } -} - -/// A `RangeStore` is a generic, B-tree-backed data structure that stores key-value pairs where the keys are ranges. -/// -/// This class allows efficient insertion, deletion, and querying of ranges, offering the flexibility to clear entire ranges -/// of values or remove single values. The underlying B-tree gives logarithmic time complexity for most operations. -/// -/// - Note: The internal keys are stored as `Range` to optimize memory usage, and are converted from `Range` -/// when interacting with public -/// -/// ```swift -/// let store = RangeStore() -/// store.insert(value: "A", range: 1..<5) -/// store.delete(overlapping: 3..<4) // Clears part of a range. -/// let results = store.ranges(overlapping: 1..<6) -/// ``` - -package final class RangeStore { - /// Using UInt32 as we can halve the memory use of keys in the tree for the small cost of converting them - /// in public calls. - typealias Key = Range - - struct KeyValue { - let key: Key - let value: Element - } - - private let order: Int - private var root: Node - - /// Initialize the store. - /// - Parameter order: The order of the internal B-tree. Defaults to `4`. - init(order: Int = 4) { - self.order = order - self.root = Node(order: self.order) - } - - /// Insert a key-value pair into the store. - /// - Parameters: - /// - value: The value to insert. - /// - range: The range to insert the value at. - func insert(value: Element, range: Range) { - let key = Key.from(range) - root.insert(value: value, range: key) - } - - /// Delete a range from the store. - /// The range must match exactly with a range in the store, or it will not be deleted. - /// See ``delete(overlapping:)`` for deleting unknown ranges. - /// - Parameter range: The range to remove. - /// - Returns: Whether or not a value was removed from the store. - @discardableResult - func delete(range: Range) -> Bool { - let key = Key.from(range) - return root.delete(range: key) - } - - /// Clears a range and all associated values. - /// - /// This is different from `delete`, which deletes a single already-known range from the store. This method removes - /// a range entirely, trimming ranges to effectively clear a range of values. - /// - /// ``` - /// 1 2 3 4 5 6 # Indices - /// |-----| |-----| # Stored Ranges - /// - /// - Call `delete` 3..<5 - /// - /// 1 2 3 4 5 6 # Indices - /// |--| |--| # Stored Ranges - /// ``` - /// - /// - Complexity: `O(n)` worst case, `O(m log n)` for small ranges where `m` is the number of results returned. - /// - Parameter range: The range to clear. - func delete(overlapping range: Range) { - let key = Key.from(range) - let keySet = IndexSet(integersIn: key.range) - - let keyPairs = root.findRanges(overlapping: key) - for pair in keyPairs { - root.delete(range: pair.key) - - // Re-Insert any ranges that overlapped with the key but weren't encapsulated. - if !key.strictContains(pair.key) { - let remainingSet = IndexSet(integersIn: pair.key.range).subtracting(keySet) - for range in remainingSet.rangeView { - let newKey = Key.from(range) - root.insert(value: pair.value, range: newKey) - } - } - } - } - - /// Search for all ranges overlapping the given range. - /// ``` - /// 1 2 3 4 5 6 # Indices - /// |-----| |-----| # Stored Ranges - /// - /// - Call `ranges(overlapping:)` 1..<5 - /// - Returns: [1..<4, 4..<7] - /// ``` - /// - Complexity: `O(n)` worst case, `O(m log n)` for small ranges where `m` is the number of results returned. - /// - Parameter range: The range to search. - /// - Returns: All key-value pairs that overlap the given range. - func ranges(overlapping range: Range) -> [(key: Range, value: Element)] { - let key = Key.from(range) - return root.findRanges(overlapping: key).map { keyValue in - (keyValue.key.generic, keyValue.value) - } - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index cd22a1a03..248edd264 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -8,11 +8,15 @@ import Foundation class StyledRangeContainer { - // TODO: Styled Range Container + private var storage: [UUID: StyledRangeStore] = [:] } extension StyledRangeContainer: HighlightProviderStateDelegate { func applyHighlightResult(provider: UUID, highlights: [HighlightRange], rangeToHighlight: NSRange) { - // TODO: Apply Result + guard let storage = storage[provider] else { + assertionFailure("No storage found for the given provider: \(provider)") + return + } + } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift new file mode 100644 index 000000000..8781f90c2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift @@ -0,0 +1,54 @@ +// +// StyledRangeStore+Internals.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/25/24 +// + +import _RopeModule + +extension StyledRangeStore { + /// Finds a Rope index, given a string offset. + /// - Parameter offset: The offset to query for. + /// - Returns: The index of the containing element in the rope. + func findIndex(at offset: Int) -> (index: Index, remaining: Int) { + _guts.find(at: offset, in: OffsetMetric(), preferEnd: false) + } +} + +extension StyledRangeStore { + /// Coalesce items before and after the given range. + /// + /// Compares the next run with the run at the given range. I they're the same, removes the next run and grows the + /// pointed-at run. + /// Performs the same operation with the preceding run, with the difference that the pointed-at run is removed + /// rather than the queried one. + /// + /// - Parameter range: The range of the item to coalesce around. + func coalesceNearby(range: Range) { + var index = findIndex(at: range.endIndex).index + if index < _guts.endIndex && _guts.index(after: index) != _guts.endIndex { + coalesceRunAfter(index: &index) + } + + index = findIndex(at: range.lowerBound).index + if index > _guts.startIndex && _guts.count > 1 { + index = _guts.index(before: index) + coalesceRunAfter(index: &index) + } + } + + /// Check if the run and the run after it are equal, and if so remove the next one and concatenate the two. + private func coalesceRunAfter(index: inout Index) { + let thisRun = _guts[index] + let nextRun = _guts[_guts.index(after: index)] + + if thisRun.styleCompare(nextRun) { + _guts.update(at: &index, by: { $0.length += nextRun.length }) + + var nextIndex = index + _guts.formIndex(after: &nextIndex) + _guts.remove(at: nextIndex) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+OffsetMetric.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+OffsetMetric.swift new file mode 100644 index 000000000..a05b68f68 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+OffsetMetric.swift @@ -0,0 +1,22 @@ +// +// StyledRangeStore+OffsetMetric.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/25/24 +// + +import _RopeModule + +extension StyledRangeStore { + struct OffsetMetric: RopeMetric { + typealias Element = StyledRun + + func size(of summary: StyledRangeStore.StyledRun.Summary) -> Int { + summary.length + } + + func index(at offset: Int, in element: StyledRangeStore.StyledRun) -> Int { + return offset + } + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift new file mode 100644 index 000000000..6fbc7c26a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift @@ -0,0 +1,93 @@ +// +// StyledRangeStore+StyledRun.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/25/24 + +import _RopeModule + +extension StyledRangeStore { + struct StyledRun { + var length: Int + let capture: CaptureName? + let modifiers: Set + + init(length: Int, capture: CaptureName?, modifiers: Set) { + self.length = length + self.capture = capture + self.modifiers = modifiers + } + + static func empty(length: Int) -> Self { + StyledRun(length: length, capture: nil, modifiers: []) + } + + /// Compare two styled ranges by their stored styles. + /// - Parameter other: The range to compare to. + /// - Returns: The result of the comparison. + func styleCompare(_ other: Self) -> Bool { + capture == other.capture && modifiers == other.modifiers + } + } +} + +extension StyledRangeStore.StyledRun: RopeElement { + typealias Index = Int + + var summary: Summary { Summary(length: length) } + + @inlinable + var isEmpty: Bool { length == 0 } + + @inlinable + var isUndersized: Bool { false } // Never undersized, pseudo-container + + func invariantCheck() {} + + mutating func rebalance(nextNeighbor right: inout Self) -> Bool { + // Never undersized + fatalError("Unimplemented") + } + + mutating func rebalance(prevNeighbor left: inout Self) -> Bool { + // Never undersized + fatalError("Unimplemented") + } + + mutating func split(at index: Self.Index) -> Self { + assert(index >= 0 && index <= length) + let tail = Self(length: length - index, capture: capture, modifiers: modifiers) + length = index + return tail + } +} + +extension StyledRangeStore.StyledRun { + struct Summary { + var length: Int + + init(length: Int) { + self.length = length + } + } +} + +extension StyledRangeStore.StyledRun.Summary: RopeSummary { + // FIXME: This is entirely arbitrary. Benchmark this. + @inline(__always) + static var maxNodeSize: Int { 10 } + + @inline(__always) + static var zero: StyledRangeStore.StyledRun.Summary { Self(length: 0) } + + @inline(__always) + var isZero: Bool { length == 0 } + + mutating func add(_ other: StyledRangeStore.StyledRun.Summary) { + length += other.length + } + + mutating func subtract(_ other: StyledRangeStore.StyledRun.Summary) { + length -= other.length + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift new file mode 100644 index 000000000..93ffd8759 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -0,0 +1,92 @@ +// +// StyledRangeStore.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/24/24 +// + +import _RopeModule + +/// StyledRangeStore is a container type that allows for setting and querying captures and modifiers for syntax +/// highlighting. The container reflects a text document in that its length needs to be kept up-to-date. +/// +/// Internally this class uses a `Rope` from the swift-collections package, allowing for efficient updates and +/// retrievals. +final class StyledRangeStore { + typealias Index = Rope.Index + var _guts = Rope() + + /// A small performance improvement for multiple identical queries, as often happens when used + /// in ``StyledRangeContainer`` + private var cache: (range: Range, runs: [Run])? + + init(documentLength: Int) { + self._guts = Rope([StyledRun(length: documentLength, capture: nil, modifiers: [])]) + } + + /// Consumer-facing value type for the stored values in this container. + struct Run { + let length: Int + let capture: CaptureName? + let modifiers: Set + } + + // MARK: - Core + + func runs(in range: Range) -> [Run] { + assert(range.lowerBound >= 0, "Negative lowerBound") + assert(range.upperBound <= _guts.count(in: OffsetMetric()), "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()) + + while index < _guts.endIndex { + let run = _guts[index] + runs.append(Run(length: run.length - (offset ?? 0), capture: run.capture, modifiers: run.modifiers)) + + index = _guts.index(after: index) + offset = nil + } + + return runs + } + + func set(capture: CaptureName, modifiers: Set, for range: Range) { + assert(range.lowerBound >= 0, "Negative lowerBound") + assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") + + let run = StyledRun(length: range.length, capture: capture, modifiers: modifiers) + _guts.replaceSubrange(range, in: OffsetMetric(), with: [run]) + + coalesceNearby(range: range) + + cache = nil + } +} + +// MARK: - Storage Sync + +extension StyledRangeStore { + func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { + assert(range.lowerBound >= 0, "Negative lowerBound") + assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") + + if newLength != 0 { + _guts.replaceSubrange(range, in: OffsetMetric(), with: [.empty(length: newLength)]) + } else { + _guts.removeSubrange(range, in: OffsetMetric()) + } + + if _guts.count > 0 { + // Coalesce nearby items if necessary. + coalesceNearby(range: Range(lowerBound: range.lowerBound, length: newLength)) + } + + cache = nil + } +} diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift b/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift deleted file mode 100644 index c7c27a5c0..000000000 --- a/Tests/CodeEditSourceEditorTests/Highlighting/RangeStoreBenchmarks.swift +++ /dev/null @@ -1,61 +0,0 @@ -import XCTest -@testable import CodeEditSourceEditor - -class RangeStoreBenchmarkTests: XCTestCase { - var rng = RandomNumberGeneratorWithSeed(seed: 942) - - // to keep these stable - struct RandomNumberGeneratorWithSeed: RandomNumberGenerator { - init(seed: Int) { srand48(seed) } - func next() -> UInt64 { return UInt64(drand48() * Double(UInt64.max)) } // swiftlint:disable:this legacy_random - } - - func test_benchmarkInsert() { - let rangeStore = RangeStore() - let numberOfInserts = 100_000 - var ranges = (0..() - let numberOfInserts = 100_000 - var ranges = (0..() - let numberOfInserts = 100_000 - var ranges = (0..() - let range1 = 0..<5 - let range2 = 5..<10 - let range3 = 10..<15 - - rangeStore.insert(value: "Value 1", range: range1) - rangeStore.insert(value: "Value 2", range: range2) - rangeStore.insert(value: "Value 3", range: range3) - - let results = rangeStore.ranges(overlapping: 0..<20) - XCTAssertEqual(results.count, 3) - XCTAssertEqual(results[0].value, "Value 1") - XCTAssertEqual(results[1].value, "Value 2") - XCTAssertEqual(results[2].value, "Value 3") - } - - func test_deleteRange() { - let rangeStore = RangeStore() - let range1 = 0..<5 - let range2 = 5..<10 - - rangeStore.insert(value: "Value 1", range: range1) - rangeStore.insert(value: "Value 2", range: range2) - - XCTAssertTrue(rangeStore.delete(range: range2)) - - let resultsAfterDelete = rangeStore.ranges(overlapping: 0..<20) - XCTAssertEqual(resultsAfterDelete.count, 1) - XCTAssertEqual(resultsAfterDelete[0].value, "Value 1") - } - - func test_insertMultipleRangesThenDelete() { - let rangeStore = RangeStore() - let range1 = 0..<5 - let range2 = 5..<10 - let range3 = 10..<15 - - rangeStore.insert(value: "Value 1", range: range1) - rangeStore.insert(value: "Value 2", range: range2) - rangeStore.insert(value: "Value 3", range: range3) - - XCTAssertTrue(rangeStore.delete(range: range1)) - XCTAssertTrue(rangeStore.delete(range: range2)) - XCTAssertTrue(rangeStore.delete(range: range3)) - - let results = rangeStore.ranges(overlapping: 0..<20) - XCTAssertTrue(results.isEmpty) - } - - func test_searchRange() { - let rangeStore = RangeStore() - let range1 = 0..<5 - let range2 = 5..<10 - - rangeStore.insert(value: "Value 1", range: range1) - rangeStore.insert(value: "Value 2", range: range2) - - let searchResults = rangeStore.ranges(overlapping: 5..<6) - XCTAssertEqual(searchResults.count, 1) - XCTAssertEqual(searchResults[0].value, "Value 2") - } - - func test_searchEmptyTree() { - let rangeStore = RangeStore() - let searchResults = rangeStore.ranges(overlapping: 0..<5) - XCTAssertTrue(searchResults.isEmpty) - } - - func test_deleteNonExistentRange() { - let rangeStore = RangeStore() - let range = 0..<5 - XCTAssertFalse(rangeStore.delete(range: range)) - } -} diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift new file mode 100644 index 000000000..2a529b436 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -0,0 +1,228 @@ +import XCTest +@testable import CodeEditSourceEditor + +extension StyledRangeStore { + var length: Int { _guts.summary.length } + var count: Int { _guts.count } +} + +final class StyledRangeStoreTests: XCTestCase { + override var continueAfterFailure: Bool { + get { false } + set { } + } + + func test_initWithLength() { + for _ in 0..<100 { + let length = Int.random(in: 0..<1000) + let store = StyledRangeStore(documentLength: length) + XCTAssertEqual(store.length, length) + } + } + + // MARK: - Storage + + func test_storageRemoveCharacters() { + let store = StyledRangeStore(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") + } + + func test_storageRemoveFromEnd() { + let store = StyledRangeStore(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") + } + + func test_storageRemoveFromBeginning() { + let store = StyledRangeStore(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") + } + + func test_storageRemoveAll() { + let store = StyledRangeStore(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") + } + + func test_storageInsert() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 45..<45, withCount: 10) + XCTAssertEqual(store.length, 110) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageInsertAtEnd() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 100..<100, withCount: 10) + XCTAssertEqual(store.length, 110) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageInsertAtBeginning() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 0..<0, withCount: 10) + XCTAssertEqual(store.length, 110) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageInsertFromEmpty() { + let store = StyledRangeStore(documentLength: 0) + store.storageUpdated(replacedCharactersIn: 0..<0, withCount: 10) + XCTAssertEqual(store.length, 10) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageEdit() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 45..<50, withCount: 10) + XCTAssertEqual(store.length, 105) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageEditAtEnd() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 95..<100, withCount: 10) + XCTAssertEqual(store.length, 105) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageEditAtBeginning() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 0..<5, withCount: 10) + XCTAssertEqual(store.length, 105) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + func test_storageEditAll() { + let store = StyledRangeStore(documentLength: 100) + store.storageUpdated(replacedCharactersIn: 0..<100, withCount: 10) + XCTAssertEqual(store.length, 10) + XCTAssertEqual(store.count, 1, "Failed to coalesce") + } + + // MARK: - Styles + + func test_setOneRun() { + let store = StyledRangeStore(documentLength: 100) + store.set(capture: .comment, modifiers: [.builtin], for: 45..<50) + XCTAssertEqual(store.length, 100) + XCTAssertEqual(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) + + XCTAssertEqual(runs[0].capture, nil) + XCTAssertEqual(runs[1].capture, .comment) + XCTAssertEqual(runs[2].capture, nil) + + XCTAssertEqual(runs[0].modifiers, []) + XCTAssertEqual(runs[1].modifiers, [.builtin]) + XCTAssertEqual(runs[2].modifiers, []) + } + + func test_queryOverlappingRun() { + let store = StyledRangeStore(documentLength: 100) + store.set(capture: .comment, modifiers: [.builtin], for: 45..<50) + XCTAssertEqual(store.length, 100) + XCTAssertEqual(store.count, 3) + + let runs = store.runs(in: 47..<100) + XCTAssertEqual(runs.count, 2) + XCTAssertEqual(runs[0].length, 3) + XCTAssertEqual(runs[1].length, 50) + + XCTAssertEqual(runs[0].capture, .comment) + XCTAssertEqual(runs[1].capture, nil) + + XCTAssertEqual(runs[0].modifiers, [.builtin]) + XCTAssertEqual(runs[1].modifiers, []) + } + + func test_setMultipleRuns() { + let store = StyledRangeStore(documentLength: 100) + + store.set(capture: .comment, modifiers: [.builtin], for: 5..<15) + store.set(capture: .keyword, modifiers: [], for: 20..<30) + store.set(capture: .string, modifiers: [.builtin], for: 35..<40) + store.set(capture: .function, modifiers: [], for: 45..<50) + store.set(capture: .variable, modifiers: [], for: 60..<70) + + XCTAssertEqual(store.length, 100) + + let runs = store.runs(in: 0..<100) + XCTAssertEqual(runs.count, 11) + XCTAssertEqual(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: [Set] = [[], [.builtin], [], [], [], [.builtin], [], [], [], [], []] + + runs.enumerated().forEach { + XCTAssertEqual($0.element.length, lengths[$0.offset]) + XCTAssertEqual($0.element.capture, captures[$0.offset]) + XCTAssertEqual($0.element.modifiers, modifiers[$0.offset]) + } + } + + func test_setMultipleRunsAndStorageUpdate() { + let store = StyledRangeStore(documentLength: 100) + + store.set(capture: .comment, modifiers: [.builtin], for: 5..<15) + store.set(capture: .keyword, modifiers: [], for: 20..<30) + store.set(capture: .string, modifiers: [.builtin], for: 35..<40) + store.set(capture: .function, modifiers: [], for: 45..<50) + store.set(capture: .variable, modifiers: [], for: 60..<70) + + XCTAssertEqual(store.length, 100) + + var runs = store.runs(in: 0..<100) + XCTAssertEqual(runs.count, 11) + XCTAssertEqual(runs.reduce(0, { $0 + $1.length }), 100) + + var lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] + var captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] + var modifiers: [Set] = [[], [.builtin], [], [], [], [.builtin], [], [], [], [], []] + + runs.enumerated().forEach { + XCTAssertEqual( + $0.element.length, + lengths[$0.offset], + "Run \($0.offset) has incorrect length: \($0.element.length). Expected \(lengths[$0.offset])" + ) + XCTAssertEqual( + $0.element.capture, + captures[$0.offset], // swiftlint:disable:next line_length + "Run \($0.offset) has incorrect capture: \(String(describing: $0.element.capture)). Expected \(String(describing: captures[$0.offset]))" + ) + XCTAssertEqual( + $0.element.modifiers, + modifiers[$0.offset], + "Run \($0.offset) has incorrect modifiers: \($0.element.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) + + lengths = [5, 10, 5, 10, 10, 5, 10, 10, 30] + captures = [nil, .comment, nil, .keyword, nil, .function, nil, .variable, nil] + modifiers = [[], [.builtin], [], [], [], [], [], [], []] + + runs.enumerated().forEach { + XCTAssertEqual($0.element.length, lengths[$0.offset]) + XCTAssertEqual($0.element.capture, captures[$0.offset]) + XCTAssertEqual($0.element.modifiers, modifiers[$0.offset]) + } + } +} From 1fa046cb9c6335c95121b10129fc90169b681551 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 26 Oct 2024 15:02:03 -0500 Subject: [PATCH 04/24] Add Multi-Run Method, Fix Tests, Update Tests --- .../Extensions/Range+Length.swift | 4 ++++ .../StyledRangeStore+Internals.swift | 2 +- .../StyledRangeStore/StyledRangeStore.swift | 11 ++++++++--- .../Highlighting/StyledRangeStoreTests.swift | 19 ++++++++++--------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift b/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift index 8e20d24e1..86f640540 100644 --- a/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift +++ b/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift @@ -10,6 +10,10 @@ import Foundation extension Range where Bound == Int { var length: Bound { upperBound - lowerBound } + /// The final index covered by this range. If the range has 0 length (upper bound = lower bound) it returns the + /// single value represented by the range (lower bound) + var lastIndex: Bound { upperBound == lowerBound ? upperBound : upperBound - 1 } + init(lowerBound: Int, length: Int) { self = lowerBound..<(lowerBound + length) } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift index 8781f90c2..b4ed35044 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift @@ -26,7 +26,7 @@ extension StyledRangeStore { /// /// - Parameter range: The range of the item to coalesce around. func coalesceNearby(range: Range) { - var index = findIndex(at: range.endIndex).index + var index = findIndex(at: range.lastIndex).index if index < _guts.endIndex && _guts.index(after: index) != _guts.endIndex { coalesceRunAfter(index: &index) } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index 93ffd8759..319bd603e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -59,12 +59,17 @@ final class StyledRangeStore { func set(capture: CaptureName, modifiers: Set, for range: Range) { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") + set(runs: [Run(length: range.length, capture: capture, modifiers: modifiers)], for: range) + } - let run = StyledRun(length: range.length, capture: capture, modifiers: modifiers) - _guts.replaceSubrange(range, in: OffsetMetric(), with: [run]) + func set(runs: [Run], for range: Range) { + _guts.replaceSubrange( + range, + in: OffsetMetric(), + with: runs.map { StyledRun(length: $0.length, capture: $0.capture, modifiers: $0.modifiers) } + ) coalesceNearby(range: range) - cache = nil } } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index 2a529b436..9bff518a0 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -176,11 +176,16 @@ final class StyledRangeStoreTests: XCTestCase { func test_setMultipleRunsAndStorageUpdate() { let store = StyledRangeStore(documentLength: 100) - store.set(capture: .comment, modifiers: [.builtin], for: 5..<15) - store.set(capture: .keyword, modifiers: [], for: 20..<30) - store.set(capture: .string, modifiers: [.builtin], for: 35..<40) - store.set(capture: .function, modifiers: [], for: 45..<50) - store.set(capture: .variable, modifiers: [], for: 60..<70) + var lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] + var captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] + var modifiers: [Set] = [[], [.builtin], [], [], [], [.builtin], [], [], [], [], []] + + store.set( + runs: zip(zip(lengths, captures), modifiers).map { + StyledRangeStore.Run(length: $0.0, capture: $0.1, modifiers: $1) + }, + for: 0..<100 + ) XCTAssertEqual(store.length, 100) @@ -188,10 +193,6 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs.count, 11) XCTAssertEqual(runs.reduce(0, { $0 + $1.length }), 100) - var lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] - var captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] - var modifiers: [Set] = [[], [.builtin], [], [], [], [.builtin], [], [], [], [], []] - runs.enumerated().forEach { XCTAssertEqual( $0.element.length, From 0713d2c8db2aec8aaf119f3869c00755239acf1e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:41:45 -0600 Subject: [PATCH 05/24] Begin `StyledRangeContainer` --- .../HighlightProviderState.swift | 5 ++- .../Highlighting/Highlighter.swift | 9 ++-- .../StyledRangeContainer.swift | 40 ++++++++++++++++-- .../StyledRangeStore/StyledRangeStore.swift | 6 ++- .../StyledRangeContainerTests.swift | 41 +++++++++++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index 1445e8c06..525cd1195 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -24,7 +24,7 @@ class HighlightProviderState { // MARK: - State /// A unique identifier for this provider. Used by the delegate to determine the source of results. - private let id: UUID = UUID() + let id: UUID /// Any indexes that highlights have been requested for, but haven't been applied. /// Indexes/ranges are added to this when highlights are requested and removed @@ -58,18 +58,21 @@ class HighlightProviderState { /// Creates a new highlight provider state object. /// Sends the `setUp` message to the highlight provider object. /// - Parameters: + /// - id: The ID of the provider /// - delegate: The delegate for this provider. Is passed information about ranges to highlight. /// - highlightProvider: The object to query for highlight information. /// - textView: The text view to highlight, used by the highlight provider. /// - visibleRangeProvider: A visible range provider for determining which ranges to query. /// - language: The language to set up the provider with. init( + id: UUID = UUID(), delegate: HighlightProviderStateDelegate, highlightProvider: HighlightProviding, textView: TextView, visibleRangeProvider: VisibleRangeProvider, language: CodeLanguage ) { + self.id = id self.delegate = delegate self.highlightProvider = highlightProvider self.textView = textView diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 1aff62870..33c775dea 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -87,14 +87,17 @@ class Highlighter: NSObject { self.textView = textView self.attributeProvider = attributeProvider self.visibleRangeProvider = VisibleRangeProvider(textView: textView) - self.rangeContainer = StyledRangeContainer() + + let providerIds = providers.indices.map({ _ in UUID() }) + self.rangeContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds) super.init() - self.providers = providers.map { + self.providers = providers.enumerated().map { (idx, provider) in HighlightProviderState( + id: providerIds[idx], delegate: rangeContainer, - highlightProvider: $0, + highlightProvider: provider, textView: textView, visibleRangeProvider: visibleRangeProvider, language: language diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 248edd264..1508b49a7 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -8,15 +8,49 @@ import Foundation class StyledRangeContainer { - private var storage: [UUID: StyledRangeStore] = [:] + typealias Run = StyledRangeStore.Run + var _storage: [UUID: StyledRangeStore] = [:] + + init(documentLength: Int, providers: [UUID]) { + for provider in providers { + _storage[provider] = StyledRangeStore(documentLength: documentLength) + } + } + + func runsIn(range: NSRange) -> [Run] { + + } + + func storageUpdated(replacedContentIn range: Range, withCount newLength: Int) { + _storage.values.forEach { + $0.storageUpdated(replacedCharactersIn: range, withCount: newLength) + } + } } extension StyledRangeContainer: HighlightProviderStateDelegate { func applyHighlightResult(provider: UUID, highlights: [HighlightRange], rangeToHighlight: NSRange) { - guard let storage = storage[provider] else { + assert(rangeToHighlight != .notFound, "NSNotFound is an invalid highlight range") + guard let storage = _storage[provider] else { assertionFailure("No storage found for the given provider: \(provider)") return } - + var runs: [Run] = [] + var lastIndex = rangeToHighlight.lowerBound + + for highlight in highlights { + if highlight.range.lowerBound != lastIndex { + runs.append(.empty(length: highlight.range.lowerBound - lastIndex)) + } + // TODO: Modifiers + runs.append(Run(length: highlight.range.length, capture: highlight.capture, modifiers: [])) + lastIndex = highlight.range.max + } + + if lastIndex != rangeToHighlight.upperBound { + runs.append(.empty(length: rangeToHighlight.upperBound - lastIndex)) + } + + storage.set(runs: runs, for: rangeToHighlight.intRange) } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index 319bd603e..826e305e2 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -25,10 +25,14 @@ final class StyledRangeStore { } /// Consumer-facing value type for the stored values in this container. - struct Run { + struct Run: Equatable, Hashable, Sendable { let length: Int let capture: CaptureName? let modifiers: Set + + static func empty(length: Int) -> Self { + Run(length: length, capture: nil, modifiers: []) + } } // MARK: - Core diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift new file mode 100644 index 000000000..8ef926106 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import CodeEditSourceEditor + +final class StyledRangeContainerTests: XCTestCase { + typealias Run = StyledRangeContainer.Run + + func test_init() { + let providers = [UUID(), UUID()] + let store = StyledRangeContainer(documentLength: 100, providers: providers) + + // Have to do string conversion due to missing Comparable conformance pre-macOS 14 + XCTAssertEqual(store._storage.keys.map(\.uuidString).sorted(), providers.map(\.uuidString).sorted()) + XCTAssert(store._storage.values.allSatisfy({ $0.length == 100 }), "One or more providers have incorrect length") + } + + func test_setHighlights() { + let providers = [UUID(), UUID()] + let store = StyledRangeContainer(documentLength: 100, providers: providers) + + store.applyHighlightResult( + provider: providers[0], + highlights: [HighlightRange(range: NSRange(location: 40, length: 10), capture: .comment)], + rangeToHighlight: NSRange(location: 0, length: 100) + ) + + XCTAssertNotNil(store._storage[providers[0]]) + XCTAssertEqual(store._storage[providers[0]]!.count, 3) + XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[0].capture, nil) + XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[1].capture, .comment) + XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[2].capture, nil) + + XCTAssertEqual( + store.runsIn(range: NSRange(location: 0, length: 100)), + [ + Run(length: 40, capture: nil, modifiers: []), + Run(length: 10, capture: .comment, modifiers: []), + Run(length: 50, capture: nil, modifiers: []) + ] + ) + } +} From f09822a0306bcf06f5ba0fce9d6898ce934aae0d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:14:51 -0600 Subject: [PATCH 06/24] First Attempt At Overlapping Ranges --- .../HighlightProviderState.swift | 7 +-- .../Highlighting/Highlighter.swift | 2 +- .../StyledRangeContainer.swift | 43 +++++++++++++++---- .../StyledRangeStore/HighlightedRun.swift | 28 ++++++++++++ .../StyledRangeStore/StyledRangeStore.swift | 12 +----- .../StyledRangeContainerTests.swift | 12 ++++-- 6 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index 525cd1195..429346500 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -11,7 +11,8 @@ import CodeEditTextView import OSLog protocol HighlightProviderStateDelegate: AnyObject { - func applyHighlightResult(provider: UUID, highlights: [HighlightRange], rangeToHighlight: NSRange) + typealias ProviderID = Int + func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) } @MainActor @@ -24,7 +25,7 @@ class HighlightProviderState { // MARK: - State /// A unique identifier for this provider. Used by the delegate to determine the source of results. - let id: UUID + let id: Int /// Any indexes that highlights have been requested for, but haven't been applied. /// Indexes/ranges are added to this when highlights are requested and removed @@ -65,7 +66,7 @@ class HighlightProviderState { /// - visibleRangeProvider: A visible range provider for determining which ranges to query. /// - language: The language to set up the provider with. init( - id: UUID = UUID(), + id: Int, delegate: HighlightProviderStateDelegate, highlightProvider: HighlightProviding, textView: TextView, diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 33c775dea..421309953 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -88,7 +88,7 @@ class Highlighter: NSObject { self.attributeProvider = attributeProvider self.visibleRangeProvider = VisibleRangeProvider(textView: textView) - let providerIds = providers.indices.map({ _ in UUID() }) + let providerIds = providers.indices.map({ $0 }) self.rangeContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds) super.init() diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 1508b49a7..1e2e6e15a 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -8,17 +8,44 @@ import Foundation class StyledRangeContainer { - typealias Run = StyledRangeStore.Run - var _storage: [UUID: StyledRangeStore] = [:] + var _storage: [ProviderID: StyledRangeStore] = [:] - init(documentLength: Int, providers: [UUID]) { + init(documentLength: Int, providers: [ProviderID]) { for provider in providers { _storage[provider] = StyledRangeStore(documentLength: documentLength) } } - func runsIn(range: NSRange) -> [Run] { - + func runsIn(range: NSRange) -> [HighlightedRun] { + // Ordered by priority, lower = higher priority. + var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } + var runs: [HighlightedRun] = [] + + 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.., withCount newLength: Int) { @@ -29,13 +56,13 @@ class StyledRangeContainer { } extension StyledRangeContainer: HighlightProviderStateDelegate { - func applyHighlightResult(provider: UUID, highlights: [HighlightRange], rangeToHighlight: NSRange) { + func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) { assert(rangeToHighlight != .notFound, "NSNotFound is an invalid highlight range") guard let storage = _storage[provider] else { assertionFailure("No storage found for the given provider: \(provider)") return } - var runs: [Run] = [] + var runs: [HighlightedRun] = [] var lastIndex = rangeToHighlight.lowerBound for highlight in highlights { @@ -43,7 +70,7 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { runs.append(.empty(length: highlight.range.lowerBound - lastIndex)) } // TODO: Modifiers - runs.append(Run(length: highlight.range.length, capture: highlight.capture, modifiers: [])) + runs.append(HighlightedRun(length: highlight.range.length, capture: highlight.capture, modifiers: [])) lastIndex = highlight.range.max } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift new file mode 100644 index 000000000..3903aea15 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift @@ -0,0 +1,28 @@ +// +// HighlightedRun.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 11/4/24. +// + +/// Consumer-facing value type for the stored values in this container. +struct HighlightedRun: Equatable, Hashable { + var length: Int + var capture: CaptureName? + var modifiers: Set + + static func empty(length: Int) -> Self { + HighlightedRun(length: length, capture: nil, modifiers: []) + } + + mutating func combineLowerPriority(_ other: borrowing HighlightedRun) { + if self.capture == nil { + self.capture = other.capture + } + self.modifiers.formUnion(other.modifiers) + } + + mutating func subtractLength(_ other: borrowing HighlightedRun) { + self.length -= other.length + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index 826e305e2..1396be3ea 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -13,6 +13,7 @@ import _RopeModule /// Internally this class uses a `Rope` from the swift-collections package, allowing for efficient updates and /// retrievals. final class StyledRangeStore { + typealias Run = HighlightedRun typealias Index = Rope.Index var _guts = Rope() @@ -24,17 +25,6 @@ final class StyledRangeStore { self._guts = Rope([StyledRun(length: documentLength, capture: nil, modifiers: [])]) } - /// Consumer-facing value type for the stored values in this container. - struct Run: Equatable, Hashable, Sendable { - let length: Int - let capture: CaptureName? - let modifiers: Set - - static func empty(length: Int) -> Self { - Run(length: length, capture: nil, modifiers: []) - } - } - // MARK: - Core func runs(in range: Range) -> [Run] { diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index 8ef926106..76dc3ed31 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -2,19 +2,19 @@ import XCTest @testable import CodeEditSourceEditor final class StyledRangeContainerTests: XCTestCase { - typealias Run = StyledRangeContainer.Run + typealias Run = HighlightedRun func test_init() { - let providers = [UUID(), UUID()] + let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) // Have to do string conversion due to missing Comparable conformance pre-macOS 14 - XCTAssertEqual(store._storage.keys.map(\.uuidString).sorted(), providers.map(\.uuidString).sorted()) + XCTAssertEqual(store._storage.keys.sorted(), providers) XCTAssert(store._storage.values.allSatisfy({ $0.length == 100 }), "One or more providers have incorrect length") } func test_setHighlights() { - let providers = [UUID(), UUID()] + let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) store.applyHighlightResult( @@ -38,4 +38,8 @@ final class StyledRangeContainerTests: XCTestCase { ] ) } + + func test_overlappingRuns() { + + } } From 5c1a696498e92b3a1847989cc580d31828477e7b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:58:17 -0600 Subject: [PATCH 07/24] Finish `StyledRangeContainer` --- .../Enums/CaptureModifiers.swift | 57 +++++++++++++- .../Highlighting/HighlightRange.swift | 9 ++- .../StyledRangeContainer.swift | 18 ++++- .../StyledRangeStore/HighlightedRun.swift | 21 ++++- .../StyledRangeStore+StyledRun.swift | 4 +- .../StyledRangeStore/StyledRangeStore.swift | 2 +- .../StyledRangeContainerTests.swift | 77 ++++++++++++++++++- .../Highlighting/StyledRangeStoreTests.swift | 18 ++--- 8 files changed, 185 insertions(+), 21 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift index f363cc571..ba0730dcf 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift @@ -5,6 +5,59 @@ // Created by Khan Winter on 10/24/24. // -enum CaptureModifiers: String, CaseIterable, Sendable { - case builtin +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokenModifiers + +enum CaptureModifiers: Int, CaseIterable, Sendable { + case declaration + case definition + case readonly + case `static` + case deprecated + case abstract + case async + case modification + case documentation + case defaultLibrary +} + +extension CaptureModifiers: CustomDebugStringConvertible { + var debugDescription: String { + switch self { + case .declaration: return "declaration" + case .definition: return "definition" + case .readonly: return "readonly" + case .static: return "static" + case .deprecated: return "deprecated" + case .abstract: return "abstract" + case .async: return "async" + case .modification: return "modification" + case .documentation: return "documentation" + case .defaultLibrary: return "defaultLibrary" + } + } +} + +struct CaptureModifierSet: OptionSet, Equatable, Hashable { + let rawValue: UInt + + static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifiers.declaration.rawValue) + static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifiers.definition.rawValue) + static let readonly = CaptureModifierSet(rawValue: 1 << CaptureModifiers.readonly.rawValue) + static let `static` = CaptureModifierSet(rawValue: 1 << CaptureModifiers.static.rawValue) + static let deprecated = CaptureModifierSet(rawValue: 1 << CaptureModifiers.deprecated.rawValue) + static let abstract = CaptureModifierSet(rawValue: 1 << CaptureModifiers.abstract.rawValue) + static let async = CaptureModifierSet(rawValue: 1 << CaptureModifiers.async.rawValue) + static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifiers.modification.rawValue) + static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifiers.documentation.rawValue) + static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifiers.defaultLibrary.rawValue) + + var values: [CaptureModifiers] { + var rawValue = self.rawValue + var values: [Int] = [] + while rawValue > 0 { + values.append(rawValue.trailingZeroBitCount) + rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) + } + return values.compactMap({ CaptureModifiers(rawValue: $0) }) + } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift index b1a3929fd..c6e7ea25b 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift @@ -10,5 +10,12 @@ import Foundation /// This struct represents a range to highlight, as well as the capture name for syntax coloring. public struct HighlightRange: Sendable { let range: NSRange - let capture: CaptureName + let capture: CaptureName? + let modifiers: CaptureModifierSet + + init(range: NSRange, capture: CaptureName?, modifiers: CaptureModifierSet = []) { + self.range = range + self.capture = capture + self.modifiers = modifiers + } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 1e2e6e15a..72d1c6fb4 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -28,9 +28,14 @@ class StyledRangeContainer { let minRunIdx = value.offset var minRun = value.element - for idx in 0.. + var modifiers: CaptureModifierSet static func empty(length: Int) -> Self { HighlightedRun(length: length, capture: nil, modifiers: []) } + var isEmpty: Bool { + capture == nil && modifiers.isEmpty + } + mutating func combineLowerPriority(_ other: borrowing HighlightedRun) { if self.capture == nil { self.capture = other.capture @@ -22,7 +26,22 @@ struct HighlightedRun: Equatable, Hashable { self.modifiers.formUnion(other.modifiers) } + mutating func combineHigherPriority(_ other: borrowing HighlightedRun) { + self.capture = other.capture ?? self.capture + self.modifiers.formUnion(other.modifiers) + } + mutating func subtractLength(_ other: borrowing HighlightedRun) { self.length -= other.length } } + +extension HighlightedRun: CustomDebugStringConvertible { + var debugDescription: String { + if isEmpty { + "\(length) (empty)" + } else { + "\(length) (\(capture?.rawValue ?? "none"), \(modifiers.values.debugDescription))" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift index 6fbc7c26a..3b859ae16 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift @@ -10,9 +10,9 @@ extension StyledRangeStore { struct StyledRun { var length: Int let capture: CaptureName? - let modifiers: Set + let modifiers: CaptureModifierSet - init(length: Int, capture: CaptureName?, modifiers: Set) { + init(length: Int, capture: CaptureName?, modifiers: CaptureModifierSet) { self.length = length self.capture = capture self.modifiers = modifiers diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index 1396be3ea..7d86e7742 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -50,7 +50,7 @@ final class StyledRangeStore { return runs } - func set(capture: CaptureName, modifiers: Set, for range: Range) { + func set(capture: CaptureName, modifiers: CaptureModifierSet, for range: Range) { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") set(runs: [Run(length: range.length, capture: capture, modifiers: modifiers)], for: range) diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index 76dc3ed31..c88bfaf3b 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -40,6 +40,81 @@ final class StyledRangeContainerTests: XCTestCase { } func test_overlappingRuns() { - + let providers = [0, 1] + let store = StyledRangeContainer(documentLength: 100, providers: providers) + + store.applyHighlightResult( + provider: providers[0], + highlights: [HighlightRange(range: NSRange(location: 40, length: 10), capture: .comment)], + rangeToHighlight: NSRange(location: 0, length: 100) + ) + + store.applyHighlightResult( + provider: providers[1], + highlights: [ + HighlightRange(range: NSRange(location: 45, length: 5), capture: nil, modifiers: [.declaration]) + ], + rangeToHighlight: NSRange(location: 0, length: 100) + ) + + XCTAssertEqual( + store.runsIn(range: NSRange(location: 0, length: 100)), + [ + Run(length: 40, capture: nil, modifiers: []), + Run(length: 5, capture: .comment, modifiers: []), + Run(length: 5, capture: .comment, modifiers: [.declaration]), + Run(length: 50, capture: nil, modifiers: []) + ] + ) + } + + func test_overlappingRunsWithMoreProviders() { + let providers = [0, 1, 2] + let store = StyledRangeContainer(documentLength: 200, providers: providers) + + store.applyHighlightResult( + provider: providers[0], + highlights: [ + HighlightRange(range: NSRange(location: 30, length: 20), capture: .comment), + HighlightRange(range: NSRange(location: 80, length: 30), capture: .string) + ], + rangeToHighlight: NSRange(location: 0, length: 200) + ) + + store.applyHighlightResult( + provider: providers[1], + highlights: [ + HighlightRange(range: NSRange(location: 35, length: 10), capture: nil, modifiers: [.declaration]), + HighlightRange(range: NSRange(location: 90, length: 15), capture: .comment, modifiers: [.static]) + ], + rangeToHighlight: NSRange(location: 0, length: 200) + ) + + store.applyHighlightResult( + provider: providers[2], + highlights: [ + HighlightRange(range: NSRange(location: 40, length: 5), capture: .function, modifiers: [.abstract]), + HighlightRange(range: NSRange(location: 100, length: 10), capture: .number, modifiers: [.modification]) + ], + rangeToHighlight: NSRange(location: 0, length: 200) + ) + + let runs = store.runsIn(range: NSRange(location: 0, length: 200)) + + XCTAssertEqual(runs.reduce(0, { $0 + $1.length}), 200) + + XCTAssertEqual(runs[0], Run(length: 30, capture: nil, modifiers: [])) + XCTAssertEqual(runs[1], Run(length: 5, capture: .comment, modifiers: [])) + XCTAssertEqual(runs[2], Run(length: 5, capture: .comment, modifiers: [.declaration])) + XCTAssertEqual(runs[3], Run(length: 5, capture: .comment, modifiers: [.abstract, .declaration])) + XCTAssertEqual(runs[4], Run(length: 5, capture: .comment, modifiers: [])) + XCTAssertEqual(runs[5], Run(length: 30, capture: nil, modifiers: [])) + XCTAssertEqual(runs[6], Run(length: 10, capture: .string, modifiers: [])) + XCTAssertEqual(runs[7], Run(length: 10, capture: .string, modifiers: [.static])) + XCTAssertEqual(runs[8], Run(length: 5, capture: .string, modifiers: [.static, .modification])) + XCTAssertEqual(runs[9], Run(length: 5, capture: .string, modifiers: [.modification])) + XCTAssertEqual(runs[10], Run(length: 90, capture: nil, modifiers: [])) + } + } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index 9bff518a0..de56b783d 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -110,7 +110,7 @@ final class StyledRangeStoreTests: XCTestCase { func test_setOneRun() { let store = StyledRangeStore(documentLength: 100) - store.set(capture: .comment, modifiers: [.builtin], for: 45..<50) + store.set(capture: .comment, modifiers: [.static], for: 45..<50) XCTAssertEqual(store.length, 100) XCTAssertEqual(store.count, 3) @@ -125,13 +125,13 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs[2].capture, nil) XCTAssertEqual(runs[0].modifiers, []) - XCTAssertEqual(runs[1].modifiers, [.builtin]) + XCTAssertEqual(runs[1].modifiers, [.static]) XCTAssertEqual(runs[2].modifiers, []) } func test_queryOverlappingRun() { let store = StyledRangeStore(documentLength: 100) - store.set(capture: .comment, modifiers: [.builtin], for: 45..<50) + store.set(capture: .comment, modifiers: [.static], for: 45..<50) XCTAssertEqual(store.length, 100) XCTAssertEqual(store.count, 3) @@ -143,16 +143,16 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs[0].capture, .comment) XCTAssertEqual(runs[1].capture, nil) - XCTAssertEqual(runs[0].modifiers, [.builtin]) + XCTAssertEqual(runs[0].modifiers, [.static]) XCTAssertEqual(runs[1].modifiers, []) } func test_setMultipleRuns() { let store = StyledRangeStore(documentLength: 100) - store.set(capture: .comment, modifiers: [.builtin], for: 5..<15) + store.set(capture: .comment, modifiers: [.static], for: 5..<15) store.set(capture: .keyword, modifiers: [], for: 20..<30) - store.set(capture: .string, modifiers: [.builtin], for: 35..<40) + store.set(capture: .string, modifiers: [.static], for: 35..<40) store.set(capture: .function, modifiers: [], for: 45..<50) store.set(capture: .variable, modifiers: [], for: 60..<70) @@ -164,7 +164,7 @@ final class StyledRangeStoreTests: XCTestCase { 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: [Set] = [[], [.builtin], [], [], [], [.builtin], [], [], [], [], []] + let modifiers: [CaptureModifierSet] = [[], [.static], [], [], [], [.static], [], [], [], [], []] runs.enumerated().forEach { XCTAssertEqual($0.element.length, lengths[$0.offset]) @@ -178,7 +178,7 @@ final class StyledRangeStoreTests: XCTestCase { var lengths = [5, 10, 5, 10, 5, 5, 5, 5, 10, 10, 30] var captures: [CaptureName?] = [nil, .comment, nil, .keyword, nil, .string, nil, .function, nil, .variable, nil] - var modifiers: [Set] = [[], [.builtin], [], [], [], [.builtin], [], [], [], [], []] + var modifiers: [CaptureModifierSet] = [[], [.static], [], [], [], [.static], [], [], [], [], []] store.set( runs: zip(zip(lengths, captures), modifiers).map { @@ -218,7 +218,7 @@ final class StyledRangeStoreTests: XCTestCase { lengths = [5, 10, 5, 10, 10, 5, 10, 10, 30] captures = [nil, .comment, nil, .keyword, nil, .function, nil, .variable, nil] - modifiers = [[], [.builtin], [], [], [], [], [], [], []] + modifiers = [[], [.static], [], [], [], [], [], [], []] runs.enumerated().forEach { XCTAssertEqual($0.element.length, lengths[$0.offset]) From f01eeb021eb1b5b5f80b09bd93141df86726eaaa Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 6 Nov 2024 10:35:05 -0600 Subject: [PATCH 08/24] Apply Styles, Update Init Params, Update Capture Representation --- .../CodeEditSourceEditor.swift | 12 +- .../TextViewController+Highlighter.swift | 35 ++--- .../Controller/TextViewController.swift | 10 +- .../Enums/CaptureModifiers.swift | 16 ++- .../Enums/CaptureName.swift | 125 ++++++++++++++++-- .../Extensions/NSEdgeInsets+Equatable.swift | 2 +- .../NSRange+/NSRange+Comparable.swift | 18 --- .../TextView+/TextView+TextFormation.swift | 4 +- .../HighlightProviderState.swift | 45 +++++-- .../Highlighting/HighlightRange.swift | 2 +- .../Highlighting/Highlighter.swift | 83 ++++++++++-- .../StyledRangeContainer.swift | 12 +- .../StyledRangeStore/HighlightedRun.swift | 2 +- .../Highlighting/VisibleRangeProvider.swift | 11 +- .../TreeSitter/TreeSitterClient.swift | 2 + 15 files changed, 278 insertions(+), 101 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 2f856a5d9..c5a6562b6 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -57,7 +57,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { editorOverscroll: CGFloat = 0, cursorPositions: Binding<[CursorPosition]>, useThemeBackground: Bool = true, - highlightProvider: HighlightProviding? = nil, + highlightProviders: [HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets? = nil, isEditable: Bool = true, isSelectable: Bool = true, @@ -78,7 +78,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.wrapLines = wrapLines self.editorOverscroll = editorOverscroll self.cursorPositions = cursorPositions - self.highlightProvider = highlightProvider + self.highlightProviders = highlightProviders self.contentInsets = contentInsets self.isEditable = isEditable self.isSelectable = isSelectable @@ -132,7 +132,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { editorOverscroll: CGFloat = 0, cursorPositions: Binding<[CursorPosition]>, useThemeBackground: Bool = true, - highlightProvider: HighlightProviding? = nil, + highlightProviders: [HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets? = nil, isEditable: Bool = true, isSelectable: Bool = true, @@ -153,7 +153,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.wrapLines = wrapLines self.editorOverscroll = editorOverscroll self.cursorPositions = cursorPositions - self.highlightProvider = highlightProvider + self.highlightProviders = highlightProviders self.contentInsets = contentInsets self.isEditable = isEditable self.isSelectable = isSelectable @@ -179,7 +179,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var editorOverscroll: CGFloat package var cursorPositions: Binding<[CursorPosition]> private var useThemeBackground: Bool - private var highlightProvider: HighlightProviding? + private var highlightProviders: [HighlightProviding] private var contentInsets: NSEdgeInsets? private var isEditable: Bool private var isSelectable: Bool @@ -204,7 +204,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { cursorPositions: cursorPositions.wrappedValue, editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, - highlightProvider: highlightProvider, + highlightProviders: highlightProviders, contentInsets: contentInsets, isEditable: isEditable, isSelectable: isSelectable, diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index e646d39f3..fddc03654 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -11,35 +11,18 @@ import SwiftTreeSitter extension TextViewController { internal func setUpHighlighter() { if let highlighter { -// textView.removeStorageDelegate(highlighter) + textView.removeStorageDelegate(highlighter) self.highlighter = nil } -// self.highlighter = Highlighter( -// textView: textView, -// highlightProvider: highlightProvider, -// theme: theme, -// attributeProvider: self, -// language: language -// ) -// textView.addStorageDelegate(highlighter!) - setHighlightProvider(self.highlightProvider) - } - - internal func setHighlightProvider(_ highlightProvider: HighlightProviding? = nil) { - var provider: HighlightProviding? - - if let highlightProvider = highlightProvider { - provider = highlightProvider - } else { - self.treeSitterClient = TreeSitterClient() - provider = self.treeSitterClient! - } - - if let provider = provider { - self.highlightProvider = provider -// highlighter?.setHighlightProvider(provider) - } + let highlighter = Highlighter( + textView: textView, + providers: highlightProviders, + attributeProvider: self, + language: language + ) + textView.addStorageDelegate(highlighter) + self.highlighter = highlighter } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 9deef964e..4d6becfc2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -109,7 +109,7 @@ public class TextViewController: NSViewController { public var useThemeBackground: Bool /// The provided highlight provider. - public var highlightProvider: HighlightProviding? + public var highlightProviders: [HighlightProviding] /// Optional insets to offset the text view in the scroll view by. public var contentInsets: NSEdgeInsets? @@ -208,7 +208,7 @@ public class TextViewController: NSViewController { cursorPositions: [CursorPosition], editorOverscroll: CGFloat, useThemeBackground: Bool, - highlightProvider: HighlightProviding?, + highlightProviders: [HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets?, isEditable: Bool, isSelectable: Bool, @@ -228,7 +228,7 @@ public class TextViewController: NSViewController { self.cursorPositions = cursorPositions self.editorOverscroll = editorOverscroll self.useThemeBackground = useThemeBackground - self.highlightProvider = highlightProvider + self.highlightProviders = highlightProviders self.contentInsets = contentInsets self.isEditable = isEditable self.isSelectable = isSelectable @@ -295,10 +295,10 @@ public class TextViewController: NSViewController { deinit { if let highlighter { -// textView.removeStorageDelegate(highlighter) + textView.removeStorageDelegate(highlighter) } highlighter = nil - highlightProvider = nil + highlightProviders.removeAll() textCoordinators.values().forEach { $0.destroy() } diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift index ba0730dcf..7e3ce26dd 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift @@ -7,7 +7,7 @@ // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokenModifiers -enum CaptureModifiers: Int, CaseIterable, Sendable { +public enum CaptureModifiers: Int8, CaseIterable, Sendable { case declaration case definition case readonly @@ -21,7 +21,7 @@ enum CaptureModifiers: Int, CaseIterable, Sendable { } extension CaptureModifiers: CustomDebugStringConvertible { - var debugDescription: String { + public var debugDescription: String { switch self { case .declaration: return "declaration" case .definition: return "definition" @@ -37,8 +37,12 @@ extension CaptureModifiers: CustomDebugStringConvertible { } } -struct CaptureModifierSet: OptionSet, Equatable, Hashable { - let rawValue: UInt +public struct CaptureModifierSet: OptionSet, Equatable, Hashable { + public let rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifiers.declaration.rawValue) static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifiers.definition.rawValue) @@ -53,9 +57,9 @@ struct CaptureModifierSet: OptionSet, Equatable, Hashable { var values: [CaptureModifiers] { var rawValue = self.rawValue - var values: [Int] = [] + var values: [Int8] = [] while rawValue > 0 { - values.append(rawValue.trailingZeroBitCount) + values.append(Int8(rawValue.trailingZeroBitCount)) rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) } return values.compactMap({ CaptureModifiers(rawValue: $0) }) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift index b73a9a251..a2c8b5452 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift @@ -6,7 +6,11 @@ // /// A collection of possible capture names for `tree-sitter` with their respected raw values. -public enum CaptureName: String, CaseIterable, Sendable { +/// +/// This is `Int8` raw representable for memory considerations. In large documents there can be *lots* of these created +/// and passed around, so representing them with a single integer is preferable to a string to save memory. +/// +public enum CaptureName: Int8, CaseIterable, Sendable { case include case constructor case keyword @@ -24,24 +28,123 @@ public enum CaptureName: String, CaseIterable, Sendable { case string case type case parameter - case typeAlternate = "type_alternate" - case variableBuiltin = "variable.builtin" - case keywordReturn = "keyword.return" - case keywordFunction = "keyword.function" + case typeAlternate + case variableBuiltin + case keywordReturn + case keywordFunction + + var alternate: CaptureName { + switch self { + case .type: + return .typeAlternate + default: + return self + } + } /// Returns a specific capture name case from a given string. + /// - Note: See ``CaptureName`` docs for why this enum isn't a raw representable. /// - Parameter string: A string to get the capture name from /// - Returns: A `CaptureNames` case - static func fromString(_ string: String?) -> CaptureName? { - CaptureName(rawValue: string ?? "") + static func fromString(_ string: String?) -> CaptureName? { // swiftlint:disable:this cyclomatic_complexity + guard let string else { return nil } + switch string { + case "include": + return .include + case "constructor": + return .constructor + case "keyword": + return .keyword + case "boolean": + return .boolean + case "repeat": + return .repeat + case "conditional": + return .conditional + case "tag": + return .tag + case "comment": + return .comment + case "variable": + return .variable + case "property": + return .property + case "function": + return .function + case "method": + return .method + case "number": + return .number + case "float": + return .float + case "string": + return .string + case "type": + return .type + case "parameter": + return .parameter + case "type_alternate": + return .typeAlternate + case "variable.builtin": + return .variableBuiltin + case "keyword.return": + return .keywordReturn + case "keyword.function": + return .keywordFunction + default: + return nil + } } - var alternate: CaptureName { + /// See ``CaptureName`` docs for why this enum isn't a raw representable. + var stringValue: String { switch self { + case .include: + return "include" + case .constructor: + return "constructor" + case .keyword: + return "keyword" + case .boolean: + return "boolean" + case .repeat: + return "`repeat`" + case .conditional: + return "conditional" + case .tag: + return "tag" + case .comment: + return "comment" + case .variable: + return "variable" + case .property: + return "property" + case .function: + return "function" + case .method: + return "method" + case .number: + return "number" + case .float: + return "float" + case .string: + return "string" case .type: - return .typeAlternate - default: - return self + return "type" + case .parameter: + return "parameter" + case .typeAlternate: + return "typeAlternate" + case .variableBuiltin: + return "variableBuiltin" + case .keywordReturn: + return "keywordReturn" + case .keywordFunction: + return "keywordFunction" } } } + +extension CaptureName: CustomDebugStringConvertible { + public var debugDescription: String { stringValue } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift index dbdcdca23..38911245f 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift @@ -7,7 +7,7 @@ import Foundation -extension NSEdgeInsets: Equatable { +extension NSEdgeInsets: @retroactive Equatable { public static func == (lhs: NSEdgeInsets, rhs: NSEdgeInsets) -> Bool { lhs.bottom == rhs.bottom && lhs.top == rhs.top && diff --git a/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift b/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift deleted file mode 100644 index 7ba206c24..000000000 --- a/Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// NSRange+Comparable.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/15/23. -// - -import Foundation - -extension NSRange: Comparable { - public static func == (lhs: NSRange, rhs: NSRange) -> Bool { - return lhs.location == rhs.location && lhs.length == rhs.length - } - - public static func < (lhs: NSRange, rhs: NSRange) -> Bool { - return lhs.location < rhs.location - } -} diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 99e80effb..162e502df 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -10,7 +10,9 @@ import CodeEditTextView import TextStory import TextFormation -extension TextView: TextInterface { +extension TextView: @retroactive TextStoring {} + +extension TextView: @retroactive TextInterface { public var selectedRange: NSRange { get { return selectionManager diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index 429346500..d95eff6a0 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -10,6 +10,7 @@ import CodeEditLanguages import CodeEditTextView import OSLog +@MainActor protocol HighlightProviderStateDelegate: AnyObject { typealias ProviderID = Int func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) @@ -94,14 +95,10 @@ class HighlightProviderState { pendingSet.removeAll() highlightInvalidRanges() } -} - -private extension HighlightProviderState { - /// Invalidates a given range and adds it to the queue to be highlighted. - /// - Parameter range: The range to invalidate. - func invalidate(range: NSRange) { - let set = IndexSet(integersIn: range) + /// Invalidates a given index set and adds it to the queue to be highlighted. + /// - Parameter set: The index set to invalidate. + func invalidate(_ set: IndexSet) { if set.isEmpty { return } @@ -110,12 +107,39 @@ private extension HighlightProviderState { highlightInvalidRanges() } +} + +extension HighlightProviderState { + func storageWillUpdate(in range: NSRange) { + guard let textView else { return } + highlightProvider?.willApplyEdit(textView: textView, range: range) + } + + func storageDidUpdate(range: NSRange, delta: Int) { + guard let textView else { return } + highlightProvider?.applyEdit(textView: textView, range: range, delta: delta) { [weak self] result in + switch result { + case .success(let invalidSet): + // Make sure we add in the edited range too + self?.invalidate(invalidSet.union(IndexSet(integersIn: range))) + case .failure(let error): + if case HighlightProvidingError.operationCancelled = error { + self?.invalidate(IndexSet(integersIn: range)) + } else { + self?.logger.error("Failed to apply edit. Query returned with error: \(error)") + } + } + } + } +} +private extension HighlightProviderState { /// Accumulates all pending ranges and calls `queryHighlights`. func highlightInvalidRanges() { var ranges: [NSRange] = [] while let nextRange = getNextRange() { ranges.append(nextRange) + pendingSet.insert(range: nextRange) } queryHighlights(for: ranges) } @@ -147,6 +171,10 @@ private extension HighlightProviderState { highlightProvider?.queryHighlightsFor(textView: textView, range: range) { [weak self] result in guard let providerId = self?.id else { return } assert(Thread.isMainThread, "Highlighted ranges called on non-main thread.") + + self?.pendingSet.remove(integersIn: range) + self?.validSet.insert(range: range) + switch result { case .success(let highlights): self?.delegate?.applyHighlightResult( @@ -155,9 +183,8 @@ private extension HighlightProviderState { rangeToHighlight: range ) case .failure: - self?.invalidate(range: range) + self?.invalidate(IndexSet(integersIn: range)) } - self?.pendingSet.remove(integersIn: range) } } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift index c6e7ea25b..97bf3b2ac 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift @@ -13,7 +13,7 @@ public struct HighlightRange: Sendable { let capture: CaptureName? let modifiers: CaptureModifierSet - init(range: NSRange, capture: CaptureName?, modifiers: CaptureModifierSet = []) { + public init(range: NSRange, capture: CaptureName?, modifiers: CaptureModifierSet = []) { self.range = range self.capture = capture self.modifiers = modifiers diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 421309953..814d40952 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -69,9 +69,9 @@ class Highlighter: NSObject { /// The object providing attributes for captures. private weak var attributeProvider: ThemeAttributesProviding? - private var rangeContainer: StyledRangeContainer + private var styleContainer: StyledRangeContainer - private var providers: [HighlightProviderState] = [] + private var highlightProviders: [HighlightProviderState] = [] private var visibleRangeProvider: VisibleRangeProvider @@ -86,17 +86,20 @@ class Highlighter: NSObject { self.language = language self.textView = textView self.attributeProvider = attributeProvider - self.visibleRangeProvider = VisibleRangeProvider(textView: textView) + + visibleRangeProvider = VisibleRangeProvider(textView: textView) let providerIds = providers.indices.map({ $0 }) - self.rangeContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds) + styleContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds) super.init() - self.providers = providers.enumerated().map { (idx, provider) in + styleContainer.delegate = self + visibleRangeProvider.delegate = self + self.highlightProviders = providers.enumerated().map { (idx, provider) in HighlightProviderState( id: providerIds[idx], - delegate: rangeContainer, + delegate: styleContainer, highlightProvider: provider, textView: textView, visibleRangeProvider: visibleRangeProvider, @@ -109,7 +112,7 @@ class Highlighter: NSObject { /// Invalidates all text in the editor. Useful for updating themes. public func invalidate() { - providers.forEach { $0.invalidate() } + highlightProviders.forEach { $0.invalidate() } } /// Sets the language and causes a re-highlight of the entire text. @@ -125,19 +128,20 @@ class Highlighter: NSObject { ) textView.layoutManager.invalidateLayoutForRect(textView.visibleRect) - providers.forEach { $0.setLanguage(language: language) } + highlightProviders.forEach { $0.setLanguage(language: language) } } deinit { self.attributeProvider = nil self.textView = nil - self.providers = [] + self.highlightProviders = [] } } -extension Highlighter: NSTextStorageDelegate { +// MARK: NSTextStorageDelegate + +extension Highlighter: @preconcurrency NSTextStorageDelegate { /// Processes an edited range in the text. - /// Will query tree-sitter for any updated indices and re-highlight only the ranges that need it. func textStorage( _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, @@ -146,9 +150,31 @@ extension Highlighter: NSTextStorageDelegate { ) { // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document // each time an attribute is applied, we check to make sure this is in response to an edit. - guard editedMask.contains(.editedCharacters) else { return } + guard editedMask.contains(.editedCharacters), let textView else { return } + if delta > 0 { + visibleRangeProvider.visibleSet.insert(range: editedRange) + } + + visibleRangeProvider.updateVisibleSet(textView: textView) + + let styleContainerRange: Range + let newLength: Int -// self.storageDidEdit(editedRange: editedRange, delta: delta) + if editedRange.length == 0 { // Deleting, editedRange is at beginning of the range that was deleted + styleContainerRange = editedRange.location..<(editedRange.location - delta) + newLength = 0 + } else { // Replacing or inserting + styleContainerRange = editedRange.location..<(editedRange.location + editedRange.length - delta) + newLength = editedRange.length + } + + styleContainer.storageUpdated( + replacedContentIn: styleContainerRange, + withCount: newLength + ) + + let providerRange = NSRange(location: editedRange.location, length: editedRange.length - delta) + highlightProviders.forEach { $0.storageDidUpdate(range: providerRange, delta: delta) } } func textStorage( @@ -158,6 +184,35 @@ extension Highlighter: NSTextStorageDelegate { changeInLength delta: Int ) { guard editedMask.contains(.editedCharacters) else { return } -// self.storageWillEdit(editedRange: editedRange) + highlightProviders.forEach { $0.storageWillUpdate(in: editedRange) } + } +} + +// MARK: - StyledRangeContainerDelegate + +extension Highlighter: StyledRangeContainerDelegate { + func styleContainerDidUpdate(in range: NSRange) { + guard let textView, let attributeProvider else { return } + textView.textStorage.beginEditing() + + let storage = textView.textStorage + + var offset = range.location + for run in styleContainer.runsIn(range: range) { + let range = NSRange(location: offset, length: run.length) + storage?.setAttributes(attributeProvider.attributesFor(run.capture), range: range) + offset += run.length + } + + textView.textStorage.endEditing() + textView.layoutManager.invalidateLayoutForRange(range) + } +} + +// MARK: - VisibleRangeProviderDelegate + +extension Highlighter: VisibleRangeProviderDelegate { + func visibleSetDidUpdate(_ newIndices: IndexSet) { + highlightProviders.forEach { $0.invalidate(newIndices) } } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 72d1c6fb4..167730021 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -7,8 +7,15 @@ import Foundation +@MainActor +protocol StyledRangeContainerDelegate: AnyObject { + func styleContainerDidUpdate(in range: NSRange) +} + +@MainActor class StyledRangeContainer { var _storage: [ProviderID: StyledRangeStore] = [:] + weak var delegate: StyledRangeContainerDelegate? init(documentLength: Int, providers: [ProviderID]) { for provider in providers { @@ -71,8 +78,10 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { var lastIndex = rangeToHighlight.lowerBound for highlight in highlights { - if highlight.range.lowerBound != lastIndex { + if highlight.range.lowerBound > lastIndex { runs.append(.empty(length: highlight.range.lowerBound - lastIndex)) + } else if highlight.range.lowerBound < lastIndex { + continue // Skip! Overlapping } runs.append( HighlightedRun( @@ -89,5 +98,6 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { } storage.set(runs: runs, for: rangeToHighlight.intRange) + delegate?.styleContainerDidUpdate(in: rangeToHighlight) } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift index c4276a968..e34d33ee8 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift @@ -41,7 +41,7 @@ extension HighlightedRun: CustomDebugStringConvertible { if isEmpty { "\(length) (empty)" } else { - "\(length) (\(capture?.rawValue ?? "none"), \(modifiers.values.debugDescription))" + "\(length) (\(capture.debugDescription), \(modifiers.values.debugDescription))" } } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift index abcb6d951..f3bb2930e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift @@ -8,8 +8,15 @@ import AppKit import CodeEditTextView +@MainActor +protocol VisibleRangeProviderDelegate: AnyObject { + func visibleSetDidUpdate(_ newIndices: IndexSet) +} + +@MainActor class VisibleRangeProvider { private weak var textView: TextView? + weak var delegate: VisibleRangeProviderDelegate? var documentRange: NSRange { textView?.documentRange ?? .notFound @@ -47,7 +54,7 @@ class VisibleRangeProvider { } } - private func updateVisibleSet(textView: TextView) { + func updateVisibleSet(textView: TextView) { if let newVisibleRange = textView.visibleTextRange { visibleSet = IndexSet(integersIn: newVisibleRange) } @@ -69,6 +76,8 @@ class VisibleRangeProvider { } updateVisibleSet(textView: textView) + + delegate?.visibleSetDidUpdate(visibleSet) } deinit { diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index 040eb75a1..f09686fa8 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -54,6 +54,8 @@ public final class TreeSitterClient: HighlightProviding { /// Optional flag to force every operation to be done on the caller's thread. var forceSyncOperation: Bool = false + public init() { } + // MARK: - Constants public enum Constants { From 957b85923def79033ad70e68ccc2716d6e34d447 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:11:02 -0600 Subject: [PATCH 09/24] Add VisibleRangeProviderTests --- Package.resolved | 4 +- .../StyledRangeStore+StyledRun.swift | 10 -- .../HighlightProviderStateTest.swift | 134 ++++++++++++++++++ .../StyledRangeContainerTests.swift | 3 +- .../Highlighting/StyledRangeStoreTests.swift | 1 + .../VisibleRangeProviderTests.swift | 1 + Tests/CodeEditSourceEditorTests/Mock.swift | 47 +++++- .../TagEditingTests.swift | 2 +- .../TextViewControllerTests.swift | 10 +- 9 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift diff --git a/Package.resolved b/Package.resolved index 4b1c88c86..7ff4a5baa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift index 3b859ae16..3fe15a150 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift @@ -12,12 +12,6 @@ extension StyledRangeStore { let capture: CaptureName? let modifiers: CaptureModifierSet - init(length: Int, capture: CaptureName?, modifiers: CaptureModifierSet) { - self.length = length - self.capture = capture - self.modifiers = modifiers - } - static func empty(length: Int) -> Self { StyledRun(length: length, capture: nil, modifiers: []) } @@ -65,10 +59,6 @@ extension StyledRangeStore.StyledRun: RopeElement { extension StyledRangeStore.StyledRun { struct Summary { var length: Int - - init(length: Int) { - self.length = length - } } } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift new file mode 100644 index 000000000..55cffd455 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift @@ -0,0 +1,134 @@ +import XCTest +import CodeEditTextView +import CodeEditLanguages +@testable import CodeEditSourceEditor + +/// Because the provider state is mostly just passing messages between providers and the highlight state, what we need +/// to test is that invalidated ranges are sent to the delegate + +class MockVisibleRangeProvider: VisibleRangeProvider { + func setVisibleSet(_ newSet: IndexSet) { + visibleSet = newSet + delegate?.visibleSetDidUpdate(visibleSet) + } +} + +class EmptyHighlightProviderStateDelegate: HighlightProviderStateDelegate { + func applyHighlightResult( + provider: ProviderID, + highlights: [HighlightRange], + rangeToHighlight: NSRange + ) { } +} + +@MainActor +final class HighlightProviderStateTest: XCTestCase { + func test_setup() { + let textView = Mock.textView() + let rangeProvider = MockVisibleRangeProvider(textView: textView) + let delegate = EmptyHighlightProviderStateDelegate() + + let setUpExpectation = XCTestExpectation(description: "Set up called.") + + let mockProvider = Mock.highlightProvider( + onSetUp: { _ in + setUpExpectation.fulfill() + }, + onApplyEdit: { _, _, _ in .success(IndexSet()) }, + onQueryHighlightsFor: { _, _ in .success([]) } + ) + + _ = HighlightProviderState( + id: 0, + delegate: delegate, + highlightProvider: mockProvider, + textView: textView, + visibleRangeProvider: rangeProvider, + language: .swift + ) + + wait(for: [setUpExpectation], timeout: 1.0) + } + + func test_setLanguage() { + let textView = Mock.textView() + let rangeProvider = MockVisibleRangeProvider(textView: textView) + let delegate = EmptyHighlightProviderStateDelegate() + + let firstSetUpExpectation = XCTestExpectation(description: "Set up called.") + let secondSetUpExpectation = XCTestExpectation(description: "Set up called.") + + let mockProvider = Mock.highlightProvider( + onSetUp: { language in + switch language { + case .c: + firstSetUpExpectation.fulfill() + case .swift: + secondSetUpExpectation.fulfill() + default: + XCTFail("Unexpected language: \(language)") + } + }, + onApplyEdit: { _, _, _ in .success(IndexSet()) }, + onQueryHighlightsFor: { _, _ in .success([]) } + ) + + let state = HighlightProviderState( + id: 0, + delegate: delegate, + highlightProvider: mockProvider, + textView: textView, + visibleRangeProvider: rangeProvider, + language: .c + ) + + wait(for: [firstSetUpExpectation], timeout: 1.0) + + state.setLanguage(language: .swift) + + wait(for: [secondSetUpExpectation], timeout: 1.0) + } + + func test_storageUpdatedRangesPassedOn() { + let textView = Mock.textView() + let rangeProvider = MockVisibleRangeProvider(textView: textView) + let delegate = EmptyHighlightProviderStateDelegate() + + var updatedRanges: [(NSRange, Int)] = [] + + let mockProvider = Mock.highlightProvider( + onSetUp: { _ in }, + onApplyEdit: { _, range, delta in + updatedRanges.append((range, delta)) + return .success(IndexSet()) + }, + onQueryHighlightsFor: { _, _ in .success([]) } + ) + + let state = HighlightProviderState( + id: 0, + delegate: delegate, + highlightProvider: mockProvider, + textView: textView, + visibleRangeProvider: rangeProvider, + language: .swift + ) + + let mockEdits: [(NSRange, Int)] = [ + (NSRange(location: 0, length: 10), 10), // Inserted 10 + (NSRange(location: 5, length: 0), -2), // Deleted 2 at 5 + (NSRange(location: 0, length: 2), 3), // Replaced 0-2 with 3 + (NSRange(location: 9, length: 1), 1), + (NSRange(location: 0, length: 0), -10) + ] + + for edit in mockEdits { + state.storageDidUpdate(range: edit.0, delta: edit.1) + } + + for (range, expected) in zip(updatedRanges, mockEdits) { + XCTAssertEqual(range.0, expected.0) + XCTAssertEqual(range.1, expected.1) + } + } +} diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index c88bfaf3b..d4fd2e9ef 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import CodeEditSourceEditor +@MainActor final class StyledRangeContainerTests: XCTestCase { typealias Run = HighlightedRun @@ -114,7 +115,5 @@ final class StyledRangeContainerTests: XCTestCase { XCTAssertEqual(runs[8], Run(length: 5, capture: .string, modifiers: [.static, .modification])) XCTAssertEqual(runs[9], Run(length: 5, capture: .string, modifiers: [.modification])) XCTAssertEqual(runs[10], Run(length: 90, capture: nil, modifiers: [])) - } - } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index de56b783d..e41e1c00f 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -6,6 +6,7 @@ extension StyledRangeStore { var count: Int { _guts.count } } +@MainActor final class StyledRangeStoreTests: XCTestCase { override var continueAfterFailure: Bool { get { false } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift index 211acd6fc..66a0b44b7 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import CodeEditSourceEditor +@MainActor final class VisibleRangeProviderTests: XCTestCase { func test_updateOnScroll() { let (scrollView, textView) = Mock.scrollingTextView() diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index e1c92bd7f..31c3e5377 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -4,6 +4,43 @@ import CodeEditTextView import CodeEditLanguages @testable import CodeEditSourceEditor +class MockHighlightProvider: HighlightProviding { + var onSetUp: (CodeLanguage) -> Void + var onApplyEdit: (_ textView: TextView, _ range: NSRange, _ delta: Int) -> Result + var onQueryHighlightsFor: (_ textView: TextView, _ range: NSRange) -> Result<[HighlightRange], any Error> + + init( + onSetUp: @escaping (CodeLanguage) -> Void, + onApplyEdit: @escaping (_: TextView, _: NSRange, _: Int) -> Result, + onQueryHighlightsFor: @escaping (_: TextView, _: NSRange) -> Result<[HighlightRange], any Error> + ) { + self.onSetUp = onSetUp + self.onApplyEdit = onApplyEdit + self.onQueryHighlightsFor = onQueryHighlightsFor + } + + func setUp(textView: TextView, codeLanguage: CodeLanguage) { + self.onSetUp(codeLanguage) + } + + func applyEdit( + textView: TextView, + range: NSRange, + delta: Int, + completion: @escaping @MainActor (Result) -> Void + ) { + completion(self.onApplyEdit(textView, range, delta)) + } + + func queryHighlightsFor( + textView: TextView, + range: NSRange, + completion: @escaping @MainActor (Result<[HighlightRange], any Error>) -> Void + ) { + completion(self.onQueryHighlightsFor(textView, range)) + } +} + enum Mock { class Delegate: TextViewDelegate { } @@ -20,7 +57,7 @@ enum Mock { cursorPositions: [], editorOverscroll: 0.5, useThemeBackground: true, - highlightProvider: nil, + highlightProviders: [TreeSitterClient()], contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), isEditable: true, isSelectable: true, @@ -96,4 +133,12 @@ enum Mock { language: language ) } + + static func highlightProvider( + onSetUp: @escaping (CodeLanguage) -> Void, + onApplyEdit: @escaping (TextView, NSRange, Int) -> Result, + onQueryHighlightsFor: @escaping (TextView, NSRange) -> Result<[HighlightRange], any Error> + ) -> MockHighlightProvider { + MockHighlightProvider(onSetUp: onSetUp, onApplyEdit: onApplyEdit, onQueryHighlightsFor: onQueryHighlightsFor) + } } diff --git a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift index eb2517467..37ada0d69 100644 --- a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift +++ b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift @@ -16,7 +16,7 @@ final class TagEditingTests: XCTestCase { controller = Mock.textViewController(theme: theme) let tsClient = Mock.treeSitterClient(forceSync: true) controller.treeSitterClient = tsClient - controller.highlightProvider = tsClient + controller.highlightProviders = [tsClient] window = NSWindow() window.contentViewController = controller controller.loadView() diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index 1f42d01f7..28b70557f 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -42,7 +42,7 @@ final class TextViewControllerTests: XCTestCase { cursorPositions: [], editorOverscroll: 0.5, useThemeBackground: true, - highlightProvider: nil, + highlightProviders: [], contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), isEditable: true, isSelectable: true, @@ -59,24 +59,24 @@ final class TextViewControllerTests: XCTestCase { func test_captureNames() throws { // test for "keyword" let captureName1 = "keyword" - let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor + let color1 = controller.attributesFor(CaptureName.fromString(captureName1))[.foregroundColor] as? NSColor XCTAssertEqual(color1, NSColor.systemPink) // test for "comment" let captureName2 = "comment" - let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor + let color2 = controller.attributesFor(CaptureName.fromString(captureName2))[.foregroundColor] as? NSColor XCTAssertEqual(color2, NSColor.systemGreen) /* ... additional tests here ... */ // test for empty case let captureName3 = "" - let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor + let color3 = controller.attributesFor(CaptureName.fromString(captureName3))[.foregroundColor] as? NSColor XCTAssertEqual(color3, NSColor.textColor) // test for random case let captureName4 = "abc123" - let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor + let color4 = controller.attributesFor(CaptureName.fromString(captureName4))[.foregroundColor] as? NSColor XCTAssertEqual(color4, NSColor.textColor) } From 8d3cf5a3d4954a1b73597484d2491600ee731808 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:04:16 -0600 Subject: [PATCH 10/24] Docs, Docs, Docs --- .../Enums/CaptureModifiers.swift | 6 +- .../HighlightProviderState.swift | 9 ++ .../Highlighting/Highlighter.swift | 89 ++++++++++--------- .../StyledRangeContainer.swift | 25 +++++- .../StyledRangeStore/HighlightedRun.swift | 6 +- .../StyledRangeStore/StyledRangeStore.swift | 19 +++- .../Highlighting/VisibleRangeProvider.swift | 2 + 7 files changed, 104 insertions(+), 52 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift index 7e3ce26dd..f9be81a37 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift @@ -37,7 +37,8 @@ extension CaptureModifiers: CustomDebugStringConvertible { } } -public struct CaptureModifierSet: OptionSet, Equatable, Hashable { +/// A set of capture modifiers, efficiently represented by a single integer. +public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { public let rawValue: UInt public init(rawValue: UInt) { @@ -54,7 +55,8 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable { static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifiers.modification.rawValue) static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifiers.documentation.rawValue) static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifiers.defaultLibrary.rawValue) - + + /// All values in the set. var values: [CaptureModifiers] { var rawValue = self.rawValue var values: [Int8] = [] diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index d95eff6a0..a68fba6be 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -16,6 +16,15 @@ protocol HighlightProviderStateDelegate: AnyObject { func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) } +/// Keeps track of the valid and pending indices for a single highlight provider in the editor. +/// +/// When ranges are invalidated, edits are made, or new text is made visible, this class is notified and queries its +/// highlight provider for invalidated indices. +/// +/// Once it knows which indices were invalidated by the edit, it queries the provider for highlights and passes the +/// results to a ``StyledRangeContainer`` to eventually be applied to the editor. +/// +/// This class will also chunk the invalid ranges to avoid performing a massive highlight query. @MainActor class HighlightProviderState { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "HighlightProviderState") diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 814d40952..eff4d3c9e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -12,50 +12,53 @@ import SwiftTreeSitter import CodeEditLanguages import OSLog -/* - +---------------------------------+ - | Highlighter | - | | - | - highlightProviders[] | - | - styledRangeContainer | - | | - | + refreshHighlightsIn(range:) | - +---------------------------------+ - | - | - v - +-------------------------------+ +-----------------------------+ - | RangeCaptureContainer | ------> | RangeStore | - | | | | - | - manages combined ranges | | - stores raw ranges & | - | - layers highlight styles | | captures | - | + getAttributesForRange() | +-----------------------------+ - +-------------------------------+ - ^ - | - | - +-------------------------------+ - | HighlightProviderState[] | (one for each provider) - | | - | - keeps valid/invalid ranges | - | - queries providers (async) | - | + updateStyledRanges() | - +-------------------------------+ - ^ - | - | - +-------------------------------+ - | HighlightProviding Object | (tree-sitter, LSP, spellcheck) - +-------------------------------+ - */ - -/// The `Highlighter` class handles efficiently highlighting the `TextView` it's provided with. -/// It will listen for text and visibility changes, and highlight syntax as needed. +/// This class manages fetching syntax highlights from providers, and applying those styles to the editor. +/// Multiple highlight providers can be used to style the editor. +/// +/// This class manages multiple objects that help perform this task: +/// - ``StyledRangeContainer`` +/// - ``StyledRangeStore`` +/// - ``VisibleRangeProvider`` +/// - ``HighlightProviderState`` +/// +/// A hierarchal overview of the highlighter system. +/// ``` +/// +---------------------------------+ +/// | Highlighter | +/// | | +/// | - highlightProviders[] | +/// | - styledRangeContainer | +/// | | +/// | + refreshHighlightsIn(range:) | +/// +---------------------------------+ +/// | +/// | Queries coalesced styles +/// v +/// +-------------------------------+ +-----------------------------+ +/// | StyledRangeContainer | ------> | StyledRangeStore[] | +/// | | | | Stores styles for one provider +/// | - manages combined ranges | | - stores raw ranges & | +/// | - layers highlight styles | | captures | +/// | + getAttributesForRange() | +-----------------------------+ +/// +-------------------------------+ +/// ^ +/// | Sends highlighted runs +/// | +/// +-------------------------------+ +/// | HighlightProviderState[] | (one for each provider) +/// | | +/// | - keeps valid/invalid ranges | +/// | - queries providers (async) | +/// | + updateStyledRanges() | +/// +-------------------------------+ +/// ^ +/// | Performs edits and sends highlight deltas, as well as calculates syntax captures for ranges +/// | +/// +-------------------------------+ +/// | HighlightProviding Object | (tree-sitter, LSP, spellcheck) +/// +-------------------------------+ +/// ``` /// -/// One should rarely have to directly modify or call methods on this class. Just keep it alive in -/// memory and it will listen for bounds changes, text changes, etc. However, to completely invalidate all -/// highlights use the ``invalidate()`` method to re-highlight all (visible) text, and the ``setLanguage`` -/// method to update the highlighter with a new language if needed. @MainActor class Highlighter: NSObject { static private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "Highlighter") diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 167730021..ac971c017 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -12,17 +12,34 @@ protocol StyledRangeContainerDelegate: AnyObject { func styleContainerDidUpdate(in range: NSRange) } +/// Stores styles for any number of style providers. Provides an API for providers to store their highlights, and for +/// the overlapping highlights to be queried for a final highlight pass. +/// +/// See ``runsIn(range:)`` for more details on how conflicting highlights are handled. @MainActor class StyledRangeContainer { var _storage: [ProviderID: StyledRangeStore] = [:] weak var delegate: StyledRangeContainerDelegate? - + + /// Initialize the container with a list of provider identifiers. Each provider is given an id, they should be + /// passed on here so highlights can be associated with a provider for conflict resolution. + /// - Parameters: + /// - documentLength: The length of the document. + /// - providers: An array of identifiers given to providers. init(documentLength: Int, providers: [ProviderID]) { for provider in providers { _storage[provider] = StyledRangeStore(documentLength: documentLength) } } + /// Coalesces all styled runs into a single continuous array of styled runs. + /// + /// When there is an overlapping, conflicting style (eg: provider 1 gives `.comment` to the range `0..<2`, and + /// provider 2 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) -> [HighlightedRun] { // Ordered by priority, lower = higher priority. var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } @@ -68,6 +85,12 @@ class StyledRangeContainer { } extension StyledRangeContainer: HighlightProviderStateDelegate { + /// Applies a highlight result from a highlight provider to the storage container. + /// - Parameters: + /// - provider: The provider sending the highlights. + /// - highlights: The highlights provided. These cannot be outside the range to highlight, must be ordered by + /// position, but do not need to be continuous. Ranges not included in these highlights will be saved as empty. + /// - rangeToHighlight: The range to apply the highlights to. func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) { assert(rangeToHighlight != .notFound, "NSNotFound is an invalid highlight range") guard let storage = _storage[provider] else { diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift index e34d33ee8..cd0c6a25a 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift @@ -19,19 +19,19 @@ struct HighlightedRun: Equatable, Hashable { capture == nil && modifiers.isEmpty } - mutating func combineLowerPriority(_ other: borrowing HighlightedRun) { + mutating package func combineLowerPriority(_ other: borrowing HighlightedRun) { if self.capture == nil { self.capture = other.capture } self.modifiers.formUnion(other.modifiers) } - mutating func combineHigherPriority(_ other: borrowing HighlightedRun) { + mutating package func combineHigherPriority(_ other: borrowing HighlightedRun) { self.capture = other.capture ?? self.capture self.modifiers.formUnion(other.modifiers) } - mutating func subtractLength(_ other: borrowing HighlightedRun) { + mutating package func subtractLength(_ other: borrowing HighlightedRun) { self.length -= other.length } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index 7d86e7742..9e327dca9 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -26,7 +26,10 @@ final class StyledRangeStore { } // MARK: - Core - + + /// Find all runs in a range. + /// - Parameter range: The range to query. + /// - Returns: A continuous array of runs representing the queried range. func runs(in range: Range) -> [Run] { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") @@ -49,13 +52,22 @@ final class StyledRangeStore { return runs } - + + /// Sets a capture and modifiers for a range. + /// - Parameters: + /// - capture: The capture to set. + /// - modifiers: The modifiers to set. + /// - range: The range to write to. func set(capture: CaptureName, modifiers: CaptureModifierSet, for range: Range) { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") set(runs: [Run(length: range.length, capture: capture, modifiers: modifiers)], for: range) } - + + /// Replaces a range in the document with an array of runs. + /// - Parameters: + /// - runs: The runs to insert. + /// - range: The range to replace. func set(runs: [Run], for range: Range) { _guts.replaceSubrange( range, @@ -71,6 +83,7 @@ final class StyledRangeStore { // MARK: - Storage Sync extension StyledRangeStore { + /// Handles keeping the internal storage in sync with the document. func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { assert(range.lowerBound >= 0, "Negative lowerBound") assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") diff --git a/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift index f3bb2930e..cc7938215 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift @@ -13,6 +13,8 @@ protocol VisibleRangeProviderDelegate: AnyObject { func visibleSetDidUpdate(_ newIndices: IndexSet) } +/// Provides information to ``HighlightProviderState``s about what text is visible in the editor. Keeps it's contents +/// in sync with a text view and notifies listeners about changes so highlights can be applied to newly visible indices. @MainActor class VisibleRangeProvider { private weak var textView: TextView? From 3e21e21447fc7b89260fb9ff75fa359436f5dc29 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:07:26 -0600 Subject: [PATCH 11/24] Remove .swiftpm folder --- .../xcschemes/CodeEditSourceEditor.xcscheme | 79 -------------- .../xcschemes/CodeEditTextView.xcscheme | 101 ------------------ 2 files changed, 180 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme deleted file mode 100644 index 96f701304..000000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditSourceEditor.xcscheme +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme deleted file mode 100644 index 3ac36c38c..000000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 88b928ea65df7dac94d42adc6acf364f6bea58ed Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:10:15 -0600 Subject: [PATCH 12/24] Lint Errors (darn whitespace) --- Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift | 2 +- .../StyledRangeContainer/StyledRangeContainer.swift | 5 +++-- .../StyledRangeStore/StyledRangeStore.swift | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift index f9be81a37..6c164f0cc 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift @@ -55,7 +55,7 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifiers.modification.rawValue) static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifiers.documentation.rawValue) static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifiers.defaultLibrary.rawValue) - + /// All values in the set. var values: [CaptureModifiers] { var rawValue = self.rawValue diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index ac971c017..95d83914a 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -20,7 +20,7 @@ protocol StyledRangeContainerDelegate: AnyObject { class StyledRangeContainer { var _storage: [ProviderID: StyledRangeStore] = [:] weak var delegate: StyledRangeContainerDelegate? - + /// Initialize the container with a list of provider identifiers. Each provider is given an id, they should be /// passed on here so highlights can be associated with a provider for conflict resolution. /// - Parameters: @@ -89,7 +89,8 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { /// - Parameters: /// - provider: The provider sending the highlights. /// - highlights: The highlights provided. These cannot be outside the range to highlight, must be ordered by - /// position, but do not need to be continuous. Ranges not included in these highlights will be saved as empty. + /// position, but do not need to be continuous. Ranges not included in these highlights will be + /// saved as empty. /// - rangeToHighlight: The range to apply the highlights to. func applyHighlightResult(provider: ProviderID, highlights: [HighlightRange], rangeToHighlight: NSRange) { assert(rangeToHighlight != .notFound, "NSNotFound is an invalid highlight range") diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index 9e327dca9..e94d2ac32 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -26,7 +26,7 @@ final class StyledRangeStore { } // MARK: - Core - + /// Find all runs in a range. /// - Parameter range: The range to query. /// - Returns: A continuous array of runs representing the queried range. @@ -52,7 +52,7 @@ final class StyledRangeStore { return runs } - + /// Sets a capture and modifiers for a range. /// - Parameters: /// - capture: The capture to set. @@ -63,7 +63,7 @@ final class StyledRangeStore { assert(range.upperBound <= _guts.count(in: OffsetMetric()), "upperBound outside valid range") set(runs: [Run(length: range.length, capture: capture, modifiers: modifiers)], for: range) } - + /// Replaces a range in the document with an array of runs. /// - Parameters: /// - runs: The runs to insert. From fec3ab1bf6ad64edda18a5538fb32c7eef28211b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:16:07 -0600 Subject: [PATCH 13/24] Remove Swift 6 Fixes (will replace in future) --- .../Extensions/NSEdgeInsets+Equatable.swift | 2 +- .../Extensions/TextView+/TextView+TextFormation.swift | 4 +--- Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift index 38911245f..dbdcdca23 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift @@ -7,7 +7,7 @@ import Foundation -extension NSEdgeInsets: @retroactive Equatable { +extension NSEdgeInsets: Equatable { public static func == (lhs: NSEdgeInsets, rhs: NSEdgeInsets) -> Bool { lhs.bottom == rhs.bottom && lhs.top == rhs.top && diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 162e502df..99e80effb 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -10,9 +10,7 @@ import CodeEditTextView import TextStory import TextFormation -extension TextView: @retroactive TextStoring {} - -extension TextView: @retroactive TextInterface { +extension TextView: TextInterface { public var selectedRange: NSRange { get { return selectionManager diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index eff4d3c9e..e1c51306c 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -143,7 +143,7 @@ class Highlighter: NSObject { // MARK: NSTextStorageDelegate -extension Highlighter: @preconcurrency NSTextStorageDelegate { +extension Highlighter: NSTextStorageDelegate { /// Processes an edited range in the text. func textStorage( _ textStorage: NSTextStorage, From f042b38d529d1fe668555b0f0477dbe0f87b2038 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:24:55 -0600 Subject: [PATCH 14/24] Fix Warnings --- .../Highlighting/HighlightProviderStateTest.swift | 4 +++- .../{ => Highlighting}/HighlighterTests.swift | 1 - .../Highlighting/StyledRangeContainerTests.swift | 5 ++++- .../Highlighting/StyledRangeStoreTests.swift | 1 - .../Highlighting/VisibleRangeProviderTests.swift | 4 +++- 5 files changed, 10 insertions(+), 5 deletions(-) rename Tests/CodeEditSourceEditorTests/{ => Highlighting}/HighlighterTests.swift (98%) diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift index 55cffd455..c161b8725 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift @@ -21,8 +21,8 @@ class EmptyHighlightProviderStateDelegate: HighlightProviderStateDelegate { ) { } } -@MainActor final class HighlightProviderStateTest: XCTestCase { + @MainActor func test_setup() { let textView = Mock.textView() let rangeProvider = MockVisibleRangeProvider(textView: textView) @@ -50,6 +50,7 @@ final class HighlightProviderStateTest: XCTestCase { wait(for: [setUpExpectation], timeout: 1.0) } + @MainActor func test_setLanguage() { let textView = Mock.textView() let rangeProvider = MockVisibleRangeProvider(textView: textView) @@ -89,6 +90,7 @@ final class HighlightProviderStateTest: XCTestCase { wait(for: [secondSetUpExpectation], timeout: 1.0) } + @MainActor func test_storageUpdatedRangesPassedOn() { let textView = Mock.textView() let rangeProvider = MockVisibleRangeProvider(textView: textView) diff --git a/Tests/CodeEditSourceEditorTests/HighlighterTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift similarity index 98% rename from Tests/CodeEditSourceEditorTests/HighlighterTests.swift rename to Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift index f255f4d31..5b6ee3223 100644 --- a/Tests/CodeEditSourceEditorTests/HighlighterTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift @@ -42,7 +42,6 @@ final class HighlighterTests: XCTestCase { func test_canceledHighlightsAreInvalidated() { let highlightProvider = MockHighlightProvider() let attributeProvider = MockAttributeProvider() - let theme = Mock.theme() let textView = Mock.textView() textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) textView.setText("Hello World!") diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index d4fd2e9ef..f9dabfa7d 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -1,10 +1,10 @@ import XCTest @testable import CodeEditSourceEditor -@MainActor final class StyledRangeContainerTests: XCTestCase { typealias Run = HighlightedRun + @MainActor func test_init() { let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) @@ -14,6 +14,7 @@ final class StyledRangeContainerTests: XCTestCase { XCTAssert(store._storage.values.allSatisfy({ $0.length == 100 }), "One or more providers have incorrect length") } + @MainActor func test_setHighlights() { let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) @@ -40,6 +41,7 @@ final class StyledRangeContainerTests: XCTestCase { ) } + @MainActor func test_overlappingRuns() { let providers = [0, 1] let store = StyledRangeContainer(documentLength: 100, providers: providers) @@ -69,6 +71,7 @@ final class StyledRangeContainerTests: XCTestCase { ) } + @MainActor func test_overlappingRunsWithMoreProviders() { let providers = [0, 1, 2] let store = StyledRangeContainer(documentLength: 200, providers: providers) diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index e41e1c00f..de56b783d 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -6,7 +6,6 @@ extension StyledRangeStore { var count: Int { _guts.count } } -@MainActor final class StyledRangeStoreTests: XCTestCase { override var continueAfterFailure: Bool { get { false } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift index 66a0b44b7..e75098d85 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift @@ -1,8 +1,8 @@ import XCTest @testable import CodeEditSourceEditor -@MainActor final class VisibleRangeProviderTests: XCTestCase { + @MainActor func test_updateOnScroll() { let (scrollView, textView) = Mock.scrollingTextView() textView.string = Array(repeating: "\n", count: 400).joined() @@ -19,6 +19,7 @@ final class VisibleRangeProviderTests: XCTestCase { XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) } + @MainActor func test_updateOnResize() { let (scrollView, textView) = Mock.scrollingTextView() textView.string = Array(repeating: "\n", count: 400).joined() @@ -38,6 +39,7 @@ final class VisibleRangeProviderTests: XCTestCase { // Skipping due to a bug in the textview that returns all indices for the visible rect // when not in a scroll view + @MainActor func _test_updateOnResizeNoScrollView() { let textView = Mock.textView() textView.frame = NSRect(x: 0, y: 0, width: 100, height: 100) From 9102955c30d48625d7e82ff19ad44917b390c17d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:29:38 -0600 Subject: [PATCH 15/24] Fix Docs Typo --- .../StyledRangeContainer/StyledRangeContainer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 95d83914a..ee68ad7e7 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -34,8 +34,8 @@ class StyledRangeContainer { /// Coalesces all styled runs into a single continuous array of styled runs. /// - /// When there is an overlapping, conflicting style (eg: provider 1 gives `.comment` to the range `0..<2`, and - /// provider 2 gives `.string` to `1..<2`), the provider with a lower identifier will be prioritized. In the example + /// 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. From 053a7d494c26d96d71e2e4ffa887f3398f3ec588 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 10 Nov 2024 14:24:24 -0600 Subject: [PATCH 16/24] Highlight Invalidation Fix --- .../HighlightProviderState.swift | 20 +++++----- .../Highlighting/Highlighter.swift | 27 ++++++++----- .../TreeSitter/TreeSitterClient.swift | 2 +- .../Highlighting/HighlighterTests.swift | 38 +++++++++++++++++++ 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index a68fba6be..7de3d9d59 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -116,6 +116,16 @@ class HighlightProviderState { highlightInvalidRanges() } + + /// Accumulates all pending ranges and calls `queryHighlights`. + func highlightInvalidRanges() { + var ranges: [NSRange] = [] + while let nextRange = getNextRange() { + ranges.append(nextRange) + pendingSet.insert(range: nextRange) + } + queryHighlights(for: ranges) + } } extension HighlightProviderState { @@ -143,16 +153,6 @@ extension HighlightProviderState { } private extension HighlightProviderState { - /// Accumulates all pending ranges and calls `queryHighlights`. - func highlightInvalidRanges() { - var ranges: [NSRange] = [] - while let nextRange = getNextRange() { - ranges.append(nextRange) - pendingSet.insert(range: nextRange) - } - queryHighlights(for: ranges) - } - /// Gets the next `NSRange` to highlight based on the invalid set, visible set, and pending set. /// - Returns: An `NSRange` to highlight if it could be fetched. func getNextRange() -> NSRange? { diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index e1c51306c..c223378c7 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -118,6 +118,10 @@ class Highlighter: NSObject { highlightProviders.forEach { $0.invalidate() } } + public func invalidate(_ set: IndexSet) { + highlightProviders.forEach { $0.invalidate(set) } + } + /// Sets the language and causes a re-highlight of the entire text. /// - Parameter language: The language to update to. public func setLanguage(language: CodeLanguage) { @@ -154,11 +158,6 @@ extension Highlighter: NSTextStorageDelegate { // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document // each time an attribute is applied, we check to make sure this is in response to an edit. guard editedMask.contains(.editedCharacters), let textView else { return } - if delta > 0 { - visibleRangeProvider.visibleSet.insert(range: editedRange) - } - - visibleRangeProvider.updateVisibleSet(textView: textView) let styleContainerRange: Range let newLength: Int @@ -176,6 +175,12 @@ extension Highlighter: NSTextStorageDelegate { withCount: newLength ) + if delta > 0 { + visibleRangeProvider.visibleSet.insert(range: editedRange) + } + + visibleRangeProvider.updateVisibleSet(textView: textView) + let providerRange = NSRange(location: editedRange.location, length: editedRange.length - delta) highlightProviders.forEach { $0.storageDidUpdate(range: providerRange, delta: delta) } } @@ -196,19 +201,23 @@ extension Highlighter: NSTextStorageDelegate { extension Highlighter: StyledRangeContainerDelegate { func styleContainerDidUpdate(in range: NSRange) { guard let textView, let attributeProvider else { return } +// textView.layoutManager.beginTransaction() textView.textStorage.beginEditing() let storage = textView.textStorage var offset = range.location for run in styleContainer.runsIn(range: range) { - let range = NSRange(location: offset, length: run.length) + guard let range = NSRange(location: offset, length: run.length).intersection(range) else { + continue + } storage?.setAttributes(attributeProvider.attributesFor(run.capture), range: range) - offset += run.length + offset += range.length } textView.textStorage.endEditing() - textView.layoutManager.invalidateLayoutForRange(range) +// textView.layoutManager.endTransaction() +// textView.layoutManager.invalidateLayoutForRange(range) } } @@ -216,6 +225,6 @@ extension Highlighter: StyledRangeContainerDelegate { extension Highlighter: VisibleRangeProviderDelegate { func visibleSetDidUpdate(_ newIndices: IndexSet) { - highlightProviders.forEach { $0.invalidate(newIndices) } + highlightProviders.forEach { $0.highlightInvalidRanges() } } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index f09686fa8..ecc03b22f 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -52,7 +52,7 @@ public final class TreeSitterClient: HighlightProviding { package var pendingEdits: Atomic<[InputEdit]> = Atomic([]) /// Optional flag to force every operation to be done on the caller's thread. - var forceSyncOperation: Bool = false + package var forceSyncOperation: Bool = false public init() { } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift index 5b6ee3223..fd33ddaaa 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift @@ -59,4 +59,42 @@ final class HighlighterTests: XCTestCase { "Highlighter did not query again after cancelling the first request" ) } + + @MainActor + func test_highlightsDoNotInvalidateEntireTextView() { + class SentryStorageDelegate: NSObject, NSTextStorageDelegate { + var editedIndices: IndexSet = IndexSet() + + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int) { + editedIndices.insert(integersIn: editedRange) + } + } + + let highlightProvider = TreeSitterClient() + highlightProvider.forceSyncOperation = true + let attributeProvider = MockAttributeProvider() + let textView = Mock.textView() + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textView.setText("func helloWorld() {\n\tprint(\"Hello World!\")\n}") + + let highlighter = Mock.highlighter( + textView: textView, + highlightProvider: highlightProvider, + attributeProvider: attributeProvider + ) + + highlighter.invalidate() + + let sentryStorage = SentryStorageDelegate() + textView.addStorageDelegate(sentryStorage) + + let invalidSet = IndexSet(integersIn: NSRange(location: 0, length: 24)) + highlighter.invalidate(invalidSet) // Invalidate first line + + XCTAssertEqual(sentryStorage.editedIndices, invalidSet) // Should only cause highlights on the first line + } } From 09e5cacebf86df9d09f7686f6ee01fb4eaee113c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:21:44 -0600 Subject: [PATCH 17/24] Rename CaptureModifiers -> CaptureModifier --- ...eModifiers.swift => CaptureModifier.swift} | 78 +++++++++++++++---- .../Enums/CaptureName.swift | 2 +- 2 files changed, 65 insertions(+), 15 deletions(-) rename Sources/CodeEditSourceEditor/Enums/{CaptureModifiers.swift => CaptureModifier.swift} (53%) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift similarity index 53% rename from Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift rename to Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift index 6c164f0cc..d3e92e0b1 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifiers.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -7,7 +7,7 @@ // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokenModifiers -public enum CaptureModifiers: Int8, CaseIterable, Sendable { +public enum CaptureModifier: Int8, CaseIterable, Sendable { case declaration case definition case readonly @@ -18,9 +18,59 @@ public enum CaptureModifiers: Int8, CaseIterable, Sendable { case modification case documentation case defaultLibrary + + var stringValue: String { + switch self { + case .declaration: + return "declaration" + case .definition: + return "definition" + case .readonly: + return "readonly" + case .static: + return "static" + case .deprecated: + return "deprecated" + case .abstract: + return "abstract" + case .async: + return "async" + case .modification: + return "modification" + case .documentation: + return "documentation" + case .defaultLibrary: + return "defaultLibrary" + } + } + + static func fromString(_ string: String) -> CaptureModifier { + switch string { + case "declaration": + return .declaration + case "definition": + return .definition + case "readonly": + return .readonly + case "static`": + return .static + case "deprecated": + return .deprecated + case "abstract": + return .abstract + case "async": + return .async + case "modification": + return .modification + case "documentation": + return .documentation + case "defaultLibrary": + return .defaultLibrary + } + } } -extension CaptureModifiers: CustomDebugStringConvertible { +extension CaptureModifier: CustomDebugStringConvertible { public var debugDescription: String { switch self { case .declaration: return "declaration" @@ -45,25 +95,25 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { self.rawValue = rawValue } - static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifiers.declaration.rawValue) - static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifiers.definition.rawValue) - static let readonly = CaptureModifierSet(rawValue: 1 << CaptureModifiers.readonly.rawValue) - static let `static` = CaptureModifierSet(rawValue: 1 << CaptureModifiers.static.rawValue) - static let deprecated = CaptureModifierSet(rawValue: 1 << CaptureModifiers.deprecated.rawValue) - static let abstract = CaptureModifierSet(rawValue: 1 << CaptureModifiers.abstract.rawValue) - static let async = CaptureModifierSet(rawValue: 1 << CaptureModifiers.async.rawValue) - static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifiers.modification.rawValue) - static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifiers.documentation.rawValue) - static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifiers.defaultLibrary.rawValue) + static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifier.declaration.rawValue) + static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifier.definition.rawValue) + static let readonly = CaptureModifierSet(rawValue: 1 << CaptureModifier.readonly.rawValue) + static let `static` = CaptureModifierSet(rawValue: 1 << CaptureModifier.static.rawValue) + static let deprecated = CaptureModifierSet(rawValue: 1 << CaptureModifier.deprecated.rawValue) + static let abstract = CaptureModifierSet(rawValue: 1 << CaptureModifier.abstract.rawValue) + static let async = CaptureModifierSet(rawValue: 1 << CaptureModifier.async.rawValue) + static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifier.modification.rawValue) + static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifier.documentation.rawValue) + static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifier.defaultLibrary.rawValue) /// All values in the set. - var values: [CaptureModifiers] { + var values: [CaptureModifier] { var rawValue = self.rawValue var values: [Int8] = [] while rawValue > 0 { values.append(Int8(rawValue.trailingZeroBitCount)) rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) } - return values.compactMap({ CaptureModifiers(rawValue: $0) }) + return values.compactMap({ CaptureModifier(rawValue: $0) }) } } diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift index a2c8b5452..668c64764 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift @@ -46,7 +46,7 @@ public enum CaptureName: Int8, CaseIterable, Sendable { /// - Note: See ``CaptureName`` docs for why this enum isn't a raw representable. /// - Parameter string: A string to get the capture name from /// - Returns: A `CaptureNames` case - static func fromString(_ string: String?) -> CaptureName? { // swiftlint:disable:this cyclomatic_complexity + public static func fromString(_ string: String?) -> CaptureName? { // swiftlint:disable:this cyclomatic_complexity guard let string else { return nil } switch string { case "include": From ba1dadbd53e14e34d3a00beaaab0f853e3103187 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:47:14 -0600 Subject: [PATCH 18/24] Fix Compile Error --- .../Enums/CaptureModifier.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift index d3e92e0b1..1129a87e0 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -19,7 +19,7 @@ public enum CaptureModifier: Int8, CaseIterable, Sendable { case documentation case defaultLibrary - var stringValue: String { + public var stringValue: String { switch self { case .declaration: return "declaration" @@ -44,7 +44,7 @@ public enum CaptureModifier: Int8, CaseIterable, Sendable { } } - static func fromString(_ string: String) -> CaptureModifier { + public static func fromString(_ string: String) -> CaptureModifier? { switch string { case "declaration": return .declaration @@ -66,6 +66,8 @@ public enum CaptureModifier: Int8, CaseIterable, Sendable { return .documentation case "defaultLibrary": return .defaultLibrary + default: + return nil } } } @@ -95,19 +97,19 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { self.rawValue = rawValue } - static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifier.declaration.rawValue) - static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifier.definition.rawValue) - static let readonly = CaptureModifierSet(rawValue: 1 << CaptureModifier.readonly.rawValue) - static let `static` = CaptureModifierSet(rawValue: 1 << CaptureModifier.static.rawValue) - static let deprecated = CaptureModifierSet(rawValue: 1 << CaptureModifier.deprecated.rawValue) - static let abstract = CaptureModifierSet(rawValue: 1 << CaptureModifier.abstract.rawValue) - static let async = CaptureModifierSet(rawValue: 1 << CaptureModifier.async.rawValue) - static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifier.modification.rawValue) - static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifier.documentation.rawValue) - static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifier.defaultLibrary.rawValue) + public static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifier.declaration.rawValue) + public static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifier.definition.rawValue) + public static let readonly = CaptureModifierSet(rawValue: 1 << CaptureModifier.readonly.rawValue) + public static let `static` = CaptureModifierSet(rawValue: 1 << CaptureModifier.static.rawValue) + public static let deprecated = CaptureModifierSet(rawValue: 1 << CaptureModifier.deprecated.rawValue) + public static let abstract = CaptureModifierSet(rawValue: 1 << CaptureModifier.abstract.rawValue) + public static let async = CaptureModifierSet(rawValue: 1 << CaptureModifier.async.rawValue) + public static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifier.modification.rawValue) + public static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifier.documentation.rawValue) + public static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifier.defaultLibrary.rawValue) /// All values in the set. - var values: [CaptureModifier] { + public var values: [CaptureModifier] { var rawValue = self.rawValue var values: [Int8] = [] while rawValue > 0 { From 2271437152e1fc80684f5144915f6de9628d7034 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:48:25 -0600 Subject: [PATCH 19/24] Update CaptureModifier.swift --- Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift index 1129a87e0..35e636de5 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -44,6 +44,7 @@ public enum CaptureModifier: Int8, CaseIterable, Sendable { } } + // swiftlint:disable:next cyclomatic_complexity public static func fromString(_ string: String) -> CaptureModifier? { switch string { case "declaration": From b7bd8fc3f7aa9dbd716f85b74a2d69113be4b7f4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Nov 2024 10:32:10 -0600 Subject: [PATCH 20/24] Add `insert` Method To Modifiers Set --- Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift index 35e636de5..ecb8a15ac 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -92,7 +92,7 @@ extension CaptureModifier: CustomDebugStringConvertible { /// A set of capture modifiers, efficiently represented by a single integer. public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { - public let rawValue: UInt + public var rawValue: UInt public init(rawValue: UInt) { self.rawValue = rawValue @@ -119,4 +119,8 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { } return values.compactMap({ CaptureModifier(rawValue: $0) }) } + + public mutating func insert(_ value: CaptureModifier) { + rawValue &= 1 << value.rawValue + } } From fc556347079b19b237359d20f7a6a407d9d87368 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:29:10 -0600 Subject: [PATCH 21/24] Typo Co-authored-by: Tom Ludwig --- .../StyledRangeStore/StyledRangeStore+Internals.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift index b4ed35044..f5f278e5e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+Internals.swift @@ -19,7 +19,7 @@ extension StyledRangeStore { extension StyledRangeStore { /// Coalesce items before and after the given range. /// - /// Compares the next run with the run at the given range. I they're the same, removes the next run and grows the + /// Compares the next run with the run at the given range. If they're the same, removes the next run and grows the /// pointed-at run. /// Performs the same operation with the preceding run, with the difference that the pointed-at run is removed /// rather than the queried one. From 327fc72e1040504aa2558cde382e1ce691ce4050 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:38:01 -0600 Subject: [PATCH 22/24] Code Style, Add Docs, Clean Tests --- .../Enums/CaptureModifier.swift | 35 +++++++++++-------- .../Enums/CaptureName.swift | 3 +- .../HighlightProviderState.swift | 4 +-- .../Highlighting/HighlightRange.swift | 6 ++-- .../StyledRangeContainer.swift | 8 ++--- .../StyledRangeStore/StyledRangeStore.swift | 2 +- ...tedRun.swift => StyledRangeStoreRun.swift} | 14 ++++---- .../HighlightProviderStateTest.swift | 24 ++++++------- .../StyledRangeContainerTests.swift | 6 ++-- .../Highlighting/StyledRangeStoreTests.swift | 6 ++-- 10 files changed, 58 insertions(+), 50 deletions(-) rename Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/{HighlightedRun.swift => StyledRangeStoreRun.swift} (74%) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift index ecb8a15ac..9ffd5b4c2 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -7,6 +7,20 @@ // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokenModifiers +/// A collection of possible syntax capture modifiers. Represented by an integer for memory efficiency, and with the +/// ability to convert to and from strings for ease of use with tools. +/// +/// These are useful for helping differentiate between similar types of syntax. Eg two variables may be declared like +/// ```swift +/// var a = 1 +/// let b = 1 +/// ``` +/// ``CaptureName`` will represent both these later in code, but combined ``CaptureModifier`` themes can differentiate +/// between constants (`b` in the example) and regular variables (`a` in the example). +/// +/// This is `Int8` raw representable for memory considerations. In large documents there can be *lots* of these created +/// and passed around, so representing them with a single integer is preferable to a string to save memory. +/// public enum CaptureModifier: Int8, CaseIterable, Sendable { case declaration case definition @@ -74,20 +88,7 @@ public enum CaptureModifier: Int8, CaseIterable, Sendable { } extension CaptureModifier: CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case .declaration: return "declaration" - case .definition: return "definition" - case .readonly: return "readonly" - case .static: return "static" - case .deprecated: return "deprecated" - case .abstract: return "abstract" - case .async: return "async" - case .modification: return "modification" - case .documentation: return "documentation" - case .defaultLibrary: return "defaultLibrary" - } - } + public var debugDescription: String { stringValue } } /// A set of capture modifiers, efficiently represented by a single integer. @@ -112,9 +113,15 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { /// All values in the set. public var values: [CaptureModifier] { var rawValue = self.rawValue + + // This set is represented by an integer, where each `1` in the binary number represents a value. + // We can treat the index of the `1` as the raw value of a ``CaptureModifier`` (the index in 0b0100 would be 2). + // This loops through each `1` in the `rawValue`, finds the represented modifier, and 0's out the `1` so we can + // get the next one using the binary & operator (0b0110 -> 0b0100 -> 0b0000 -> finish). var values: [Int8] = [] while rawValue > 0 { values.append(Int8(rawValue.trailingZeroBitCount)) + // Clears the bit at the desired index (0b011 & 0b110 = 0b010 if clearing index 0) rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) } return values.compactMap({ CaptureModifier(rawValue: $0) }) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift index 668c64764..32b37aa0d 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift @@ -5,7 +5,8 @@ // Created by Lukas Pistrol on 16.08.22. // -/// A collection of possible capture names for `tree-sitter` with their respected raw values. +/// A collection of possible syntax capture types. Represented by an integer for memory efficiency, and with the +/// ability to convert to and from strings for ease of use with tools. /// /// This is `Int8` raw representable for memory considerations. In large documents there can be *lots* of these created /// and passed around, so representing them with a single integer is preferable to a string to save memory. diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index 7de3d9d59..58e591307 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -40,10 +40,10 @@ class HighlightProviderState { /// Any indexes that highlights have been requested for, but haven't been applied. /// Indexes/ranges are added to this when highlights are requested and removed /// after they are applied - private var pendingSet: IndexSet = .init() + private var pendingSet: IndexSet = IndexSet() /// The set of valid indexes - private var validSet: IndexSet = .init() + private var validSet: IndexSet = IndexSet() // MARK: - Providers diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift index 97bf3b2ac..ee730954f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift @@ -9,9 +9,9 @@ import Foundation /// This struct represents a range to highlight, as well as the capture name for syntax coloring. public struct HighlightRange: Sendable { - let range: NSRange - let capture: CaptureName? - let modifiers: CaptureModifierSet + public let range: NSRange + public let capture: CaptureName? + public let modifiers: CaptureModifierSet public init(range: NSRange, capture: CaptureName?, modifiers: CaptureModifierSet = []) { self.range = range diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index ee68ad7e7..57c680747 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -40,10 +40,10 @@ class StyledRangeContainer { /// /// - Parameter range: The range to query. /// - Returns: An array of continuous styled runs. - func runsIn(range: NSRange) -> [HighlightedRun] { + func runsIn(range: NSRange) -> [StyledRangeStoreRun] { // Ordered by priority, lower = higher priority. var allRuns = _storage.sorted(by: { $0.key < $1.key }).map { $0.value.runs(in: range.intRange) } - var runs: [HighlightedRun] = [] + var runs: [StyledRangeStoreRun] = [] var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) @@ -98,7 +98,7 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { assertionFailure("No storage found for the given provider: \(provider)") return } - var runs: [HighlightedRun] = [] + var runs: [StyledRangeStoreRun] = [] var lastIndex = rangeToHighlight.lowerBound for highlight in highlights { @@ -108,7 +108,7 @@ extension StyledRangeContainer: HighlightProviderStateDelegate { continue // Skip! Overlapping } runs.append( - HighlightedRun( + StyledRangeStoreRun( length: highlight.range.length, capture: highlight.capture, modifiers: highlight.modifiers diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift index e94d2ac32..21d6bda4a 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -13,7 +13,7 @@ import _RopeModule /// Internally this class uses a `Rope` from the swift-collections package, allowing for efficient updates and /// retrievals. final class StyledRangeStore { - typealias Run = HighlightedRun + typealias Run = StyledRangeStoreRun typealias Index = Rope.Index var _guts = Rope() diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift similarity index 74% rename from Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift rename to Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift index cd0c6a25a..06335edba 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/HighlightedRun.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift @@ -1,42 +1,42 @@ // -// HighlightedRun.swift +// StyledRangeStoreRun.swift // CodeEditSourceEditor // // Created by Khan Winter on 11/4/24. // /// Consumer-facing value type for the stored values in this container. -struct HighlightedRun: Equatable, Hashable { +struct StyledRangeStoreRun: Equatable, Hashable { var length: Int var capture: CaptureName? var modifiers: CaptureModifierSet static func empty(length: Int) -> Self { - HighlightedRun(length: length, capture: nil, modifiers: []) + StyledRangeStoreRun(length: length, capture: nil, modifiers: []) } var isEmpty: Bool { capture == nil && modifiers.isEmpty } - mutating package func combineLowerPriority(_ other: borrowing HighlightedRun) { + mutating package func combineLowerPriority(_ other: borrowing StyledRangeStoreRun) { if self.capture == nil { self.capture = other.capture } self.modifiers.formUnion(other.modifiers) } - mutating package func combineHigherPriority(_ other: borrowing HighlightedRun) { + mutating package func combineHigherPriority(_ other: borrowing StyledRangeStoreRun) { self.capture = other.capture ?? self.capture self.modifiers.formUnion(other.modifiers) } - mutating package func subtractLength(_ other: borrowing HighlightedRun) { + mutating package func subtractLength(_ other: borrowing StyledRangeStoreRun) { self.length -= other.length } } -extension HighlightedRun: CustomDebugStringConvertible { +extension StyledRangeStoreRun: CustomDebugStringConvertible { var debugDescription: String { if isEmpty { "\(length) (empty)" diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift index c161b8725..d3f89f0cc 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift @@ -22,12 +22,20 @@ class EmptyHighlightProviderStateDelegate: HighlightProviderStateDelegate { } final class HighlightProviderStateTest: XCTestCase { + var textView: TextView! + var rangeProvider: MockVisibleRangeProvider! + var delegate: EmptyHighlightProviderStateDelegate! + @MainActor - func test_setup() { - let textView = Mock.textView() - let rangeProvider = MockVisibleRangeProvider(textView: textView) - let delegate = EmptyHighlightProviderStateDelegate() + override func setUp() async throws { + try await super.setUp() + textView = Mock.textView() + rangeProvider = MockVisibleRangeProvider(textView: textView) + delegate = EmptyHighlightProviderStateDelegate() + } + @MainActor + func test_setup() { let setUpExpectation = XCTestExpectation(description: "Set up called.") let mockProvider = Mock.highlightProvider( @@ -52,10 +60,6 @@ final class HighlightProviderStateTest: XCTestCase { @MainActor func test_setLanguage() { - let textView = Mock.textView() - let rangeProvider = MockVisibleRangeProvider(textView: textView) - let delegate = EmptyHighlightProviderStateDelegate() - let firstSetUpExpectation = XCTestExpectation(description: "Set up called.") let secondSetUpExpectation = XCTestExpectation(description: "Set up called.") @@ -92,10 +96,6 @@ final class HighlightProviderStateTest: XCTestCase { @MainActor func test_storageUpdatedRangesPassedOn() { - let textView = Mock.textView() - let rangeProvider = MockVisibleRangeProvider(textView: textView) - let delegate = EmptyHighlightProviderStateDelegate() - var updatedRanges: [(NSRange, Int)] = [] let mockProvider = Mock.highlightProvider( diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift index f9dabfa7d..1ea05fc20 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import CodeEditSourceEditor final class StyledRangeContainerTests: XCTestCase { - typealias Run = HighlightedRun + typealias Run = StyledRangeStoreRun @MainActor func test_init() { @@ -27,9 +27,9 @@ final class StyledRangeContainerTests: XCTestCase { XCTAssertNotNil(store._storage[providers[0]]) XCTAssertEqual(store._storage[providers[0]]!.count, 3) - XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[0].capture, nil) + XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[0].capture) XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[1].capture, .comment) - XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[2].capture, nil) + XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[2].capture) XCTAssertEqual( store.runsIn(range: NSRange(location: 0, length: 100)), diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift index de56b783d..0395e74b1 100644 --- a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -120,9 +120,9 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs[1].length, 5) XCTAssertEqual(runs[2].length, 50) - XCTAssertEqual(runs[0].capture, nil) + XCTAssertNil(runs[0].capture) XCTAssertEqual(runs[1].capture, .comment) - XCTAssertEqual(runs[2].capture, nil) + XCTAssertNil(runs[2].capture) XCTAssertEqual(runs[0].modifiers, []) XCTAssertEqual(runs[1].modifiers, [.static]) @@ -141,7 +141,7 @@ final class StyledRangeStoreTests: XCTestCase { XCTAssertEqual(runs[1].length, 50) XCTAssertEqual(runs[0].capture, .comment) - XCTAssertEqual(runs[1].capture, nil) + XCTAssertNil(runs[1].capture) XCTAssertEqual(runs[0].modifiers, [.static]) XCTAssertEqual(runs[1].modifiers, []) From 53605e45d5873377d4681f326aa1c0d2a39589df Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:43:48 -0600 Subject: [PATCH 23/24] Slight Doc Rewrite --- Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift index 9ffd5b4c2..34bf8653d 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -115,13 +115,13 @@ public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { var rawValue = self.rawValue // This set is represented by an integer, where each `1` in the binary number represents a value. - // We can treat the index of the `1` as the raw value of a ``CaptureModifier`` (the index in 0b0100 would be 2). - // This loops through each `1` in the `rawValue`, finds the represented modifier, and 0's out the `1` so we can - // get the next one using the binary & operator (0b0110 -> 0b0100 -> 0b0000 -> finish). + // We can interpret the index of the `1` as the raw value of a ``CaptureModifier`` (the index in 0b0100 would + // be 2). This loops through each `1` in the `rawValue`, finds the represented modifier, and 0's out the `1` so + // we can get the next one using the binary & operator (0b0110 -> 0b0100 -> 0b0000 -> finish). var values: [Int8] = [] while rawValue > 0 { values.append(Int8(rawValue.trailingZeroBitCount)) - // Clears the bit at the desired index (0b011 & 0b110 = 0b010 if clearing index 0) + // Clears the bit at the desired index (eg: 0b110 if clearing index 0) rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) } return values.compactMap({ CaptureModifier(rawValue: $0) }) From 4c0221ee8ec52510a159b4d6b49c753ebaad57d1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 17 Nov 2024 14:24:41 -0600 Subject: [PATCH 24/24] Only Invalidate Cancellations --- .../HighlighProviding/HighlightProviderState.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift index 58e591307..6fca2123b 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -191,8 +191,13 @@ private extension HighlightProviderState { highlights: highlights, rangeToHighlight: range ) - case .failure: - self?.invalidate(IndexSet(integersIn: range)) + case .failure(let error): + // Only invalidate if it was cancelled. + if let error = error as? HighlightProvidingError, error == .operationCancelled { + self?.invalidate(IndexSet(integersIn: range)) + } else { + self?.logger.debug("Highlighter Error: \(error.localizedDescription)") + } } } }