From 098e64849ed9256cf2618cd5f84c50630dcd0109 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:37:14 -0600 Subject: [PATCH] Multiple Highlighter Support (#273) ### Description Adds support for multiple highlight providers. > [!NOTE] > For reviewers: You may notice that this uses an underscored module `_RopeModule`. This module is safe, as in it has tests and is used in production (it backs AttributedString and BigString Foundation types). It's underscored because the API may change in the future, and the swift-collections devs consider it to be the incorrect collection type to use for most applications. However, this application meets every requirement for using a Rope. ### Related Issues * #40 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots N/A --------- Co-authored-by: Tom Ludwig --- .../xcschemes/CodeEditTextView.xcscheme | 101 ----- Package.resolved | 4 +- .../CodeEditSourceEditor.swift | 12 +- .../TextViewController+Highlighter.swift | 25 +- .../Controller/TextViewController.swift | 8 +- .../Enums/CaptureModifier.swift | 133 +++++++ .../Enums/CaptureName.swift | 128 +++++- .../NSRange+/NSRange+Comparable.swift | 18 - .../Extensions/Range+Length.swift | 20 + .../TextView+/TextView+createReadBlock.swift | 4 +- .../HighlightProviderState.swift | 205 ++++++++++ .../HighlightProviding.swift | 0 .../Highlighting/HighlightRange.swift | 11 +- .../Highlighter+NSTextStorageDelegate.swift | 36 -- .../Highlighting/Highlighter.swift | 376 +++++++----------- .../Highlighting/HighlighterTextView.swift | 16 - .../StyledRangeContainer.swift | 127 ++++++ .../StyledRangeStore+Internals.swift | 54 +++ .../StyledRangeStore+OffsetMetric.swift | 22 + .../StyledRangeStore+StyledRun.swift | 83 ++++ .../StyledRangeStore/StyledRangeStore.swift | 104 +++++ .../StyledRangeStoreRun.swift | 47 +++ .../Highlighting/VisibleRangeProvider.swift | 88 ++++ .../TreeSitter/TreeSitterClient.swift | 4 +- .../HighlightProviderStateTest.swift | 136 +++++++ .../{ => Highlighting}/HighlighterTests.swift | 40 +- .../StyledRangeContainerTests.swift | 122 ++++++ .../Highlighting/StyledRangeStoreTests.swift | 229 +++++++++++ .../VisibleRangeProviderTests.swift | 58 +++ Tests/CodeEditSourceEditorTests/Mock.swift | 63 ++- .../TagEditingTests.swift | 2 +- .../TextViewControllerTests.swift | 10 +- 32 files changed, 1814 insertions(+), 472 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme create mode 100644 Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift delete mode 100644 Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+Comparable.swift create mode 100644 Sources/CodeEditSourceEditor/Extensions/Range+Length.swift 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/StyledRangeContainer.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 create mode 100644 Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift create mode 100644 Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift rename Tests/CodeEditSourceEditorTests/{ => Highlighting}/HighlighterTests.swift (58%) create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift create mode 100644 Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/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 0ad8597e9..fddc03654 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -15,31 +15,14 @@ extension TextViewController { self.highlighter = nil } - self.highlighter = Highlighter( + let highlighter = Highlighter( textView: textView, - highlightProvider: highlightProvider, - theme: theme, + providers: highlightProviders, 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) - } + textView.addStorageDelegate(highlighter) + self.highlighter = highlighter } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 57baea8b9..edec536a7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -113,7 +113,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? @@ -217,7 +217,7 @@ public class TextViewController: NSViewController { cursorPositions: [CursorPosition], editorOverscroll: CGFloat, useThemeBackground: Bool, - highlightProvider: HighlightProviding?, + highlightProviders: [HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets?, isEditable: Bool, isSelectable: Bool, @@ -237,7 +237,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 @@ -307,7 +307,7 @@ public class TextViewController: NSViewController { textView.removeStorageDelegate(highlighter) } highlighter = nil - highlightProvider = nil + highlightProviders.removeAll() textCoordinators.values().forEach { $0.destroy() } diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift new file mode 100644 index 000000000..34bf8653d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift @@ -0,0 +1,133 @@ +// +// CaptureModifiers.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/24/24. +// + +// 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 + case readonly + case `static` + case deprecated + case abstract + case async + case modification + case documentation + case defaultLibrary + + public 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" + } + } + + // swiftlint:disable:next cyclomatic_complexity + public 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 + default: + return nil + } + } +} + +extension CaptureModifier: CustomDebugStringConvertible { + public var debugDescription: String { stringValue } +} + +/// A set of capture modifiers, efficiently represented by a single integer. +public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { + public var rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = 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. + 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 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 (eg: 0b110 if clearing index 0) + rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) + } + return values.compactMap({ CaptureModifier(rawValue: $0) }) + } + + public mutating func insert(_ value: CaptureModifier) { + rawValue &= 1 << value.rawValue + } +} diff --git a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift index b73a9a251..32b37aa0d 100644 --- a/Sources/CodeEditSourceEditor/Enums/CaptureName.swift +++ b/Sources/CodeEditSourceEditor/Enums/CaptureName.swift @@ -5,8 +5,13 @@ // Created by Lukas Pistrol on 16.08.22. // -/// A collection of possible capture names for `tree-sitter` with their respected raw values. -public enum CaptureName: String, CaseIterable, Sendable { +/// 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. +/// +public enum CaptureName: Int8, CaseIterable, Sendable { case include case constructor case keyword @@ -24,24 +29,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 ?? "") + public 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/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/Range+Length.swift b/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift new file mode 100644 index 000000000..86f640540 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/Range+Length.swift @@ -0,0 +1,20 @@ +// +// Range+Length.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/25/24. +// + +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/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..6fca2123b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift @@ -0,0 +1,205 @@ +// +// HighlightProviderState.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/13/24. +// + +import Foundation +import CodeEditLanguages +import CodeEditTextView +import OSLog + +@MainActor +protocol HighlightProviderStateDelegate: AnyObject { + typealias ProviderID = Int + 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") + + /// 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. + 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 + /// after they are applied + private var pendingSet: IndexSet = IndexSet() + + /// The set of valid indexes + private var validSet: IndexSet = IndexSet() + + // 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: + /// - 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: Int, + delegate: HighlightProviderStateDelegate, + highlightProvider: HighlightProviding, + textView: TextView, + visibleRangeProvider: VisibleRangeProvider, + language: CodeLanguage + ) { + self.id = id + 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() + } + + /// 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 + } + + validSet.subtract(set) + + 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 { + 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 { + /// 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.") + + self?.pendingSet.remove(integersIn: range) + self?.validSet.insert(range: range) + + switch result { + case .success(let highlights): + self?.delegate?.applyHighlightResult( + provider: providerId, + highlights: highlights, + rangeToHighlight: 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)") + } + } + } + } + } +} 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..ee730954f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift @@ -9,6 +9,13 @@ 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? + public let range: NSRange + public let capture: CaptureName? + public let 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+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..c223378c7 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -12,106 +12,121 @@ import SwiftTreeSitter import CodeEditLanguages import OSLog -/// 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 direcly 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") - // 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 styleContainer: StyledRangeContainer - /// Calculates invalidated ranges given an edit. - private(set) weak var highlightProvider: HighlightProviding? + private var highlightProviders: [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 - super.init() + visibleRangeProvider = VisibleRangeProvider(textView: textView) - highlightProvider?.setUp(textView: textView, codeLanguage: language) + let providerIds = providers.indices.map({ $0 }) + styleContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds) - if let scrollView = textView.enclosingScrollView { - NotificationCenter.default.addObserver( - self, - selector: #selector(visibleTextChanged(_:)), - name: NSView.frameDidChangeNotification, - object: scrollView - ) + super.init() - NotificationCenter.default.addObserver( - self, - selector: #selector(visibleTextChanged(_:)), - name: NSView.boundsDidChangeNotification, - object: scrollView.contentView + styleContainer.delegate = self + visibleRangeProvider.delegate = self + self.highlightProviders = providers.enumerated().map { (idx, provider) in + HighlightProviderState( + id: providerIds[idx], + delegate: styleContainer, + highlightProvider: provider, + 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) + 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) { 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,214 +134,97 @@ 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() + highlightProviders.forEach { $0.setLanguage(language: language) } } deinit { - NotificationCenter.default.removeObserver(self) self.attributeProvider = nil self.textView = nil - self.highlightProvider = nil + self.highlightProviders = [] } } -// 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) - - highlightInvalidRanges() - } +// MARK: NSTextStorageDelegate - /// 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) +extension Highlighter: NSTextStorageDelegate { + /// Processes an edited range in the text. + 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), let textView else { return } + + let styleContainerRange: Range + let newLength: Int + + 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 } - queryHighlights(for: rangesToQuery) - } + styleContainer.storageUpdated( + replacedContentIn: styleContainerRange, + withCount: newLength + ) - /// 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) - } - } + if delta > 0 { + visibleRangeProvider.visibleSet.insert(range: editedRange) } - } - - /// 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)) - } + visibleRangeProvider.updateVisibleSet(textView: textView) - textView?.textStorage.endEditing() - textView?.layoutManager.invalidateLayoutForRange(rangeToHighlight) - } + let providerRange = NSRange(location: editedRange.location, length: editedRange.length - delta) + highlightProviders.forEach { $0.storageDidUpdate(range: providerRange, delta: delta) } } - /// 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) - ) + func textStorage( + _ textStorage: NSTextStorage, + willProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { return } + highlightProviders.forEach { $0.storageWillUpdate(in: editedRange) } } } -// 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 - } +// MARK: - StyledRangeContainerDelegate - updateVisibleSet(textView: textView) +extension Highlighter: StyledRangeContainerDelegate { + func styleContainerDidUpdate(in range: NSRange) { + guard let textView, let attributeProvider else { return } +// textView.layoutManager.beginTransaction() + textView.textStorage.beginEditing() - // Any indices that are both *not* valid and in the visible text range should be invalidated - let newlyInvalidSet = visibleSet.subtracting(validSet) + let storage = textView.textStorage - for range in newlyInvalidSet.rangeView.map({ NSRange($0) }) { - invalidate(range: range) + var offset = range.location + for run in styleContainer.runsIn(range: range) { + guard let range = NSRange(location: offset, length: run.length).intersection(range) else { + continue + } + storage?.setAttributes(attributeProvider.attributesFor(run.capture), range: range) + offset += range.length } + + textView.textStorage.endEditing() +// textView.layoutManager.endTransaction() +// textView.layoutManager.invalidateLayoutForRange(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)") - } - } - } - } +// MARK: - VisibleRangeProviderDelegate - func storageWillEdit(editedRange: NSRange) { - guard let textView else { return } - highlightProvider?.willApplyEdit(textView: textView, range: editedRange) +extension Highlighter: VisibleRangeProviderDelegate { + func visibleSetDidUpdate(_ newIndices: IndexSet) { + highlightProviders.forEach { $0.highlightInvalidRanges() } } } 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/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift new file mode 100644 index 000000000..57c680747 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -0,0 +1,127 @@ +// +// StyledRangeContainer.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/13/24. +// + +import Foundation + +@MainActor +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 2 gives `.comment` to the range `0..<2`, and + /// provider 1 gives `.string` to `1..<2`), the provider with a lower identifier will be prioritized. In the example + /// case, the final value would be `0..<1=.comment` and `1..<2=.string`. + /// + /// - Parameter range: The range to query. + /// - Returns: An array of continuous styled runs. + func runsIn(range: NSRange) -> [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: [StyledRangeStoreRun] = [] + + 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) { + _storage.values.forEach { + $0.storageUpdated(replacedCharactersIn: range, withCount: newLength) + } + } +} + +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 { + assertionFailure("No storage found for the given provider: \(provider)") + return + } + var runs: [StyledRangeStoreRun] = [] + var lastIndex = rangeToHighlight.lowerBound + + for highlight in highlights { + if highlight.range.lowerBound > lastIndex { + runs.append(.empty(length: highlight.range.lowerBound - lastIndex)) + } else if highlight.range.lowerBound < lastIndex { + continue // Skip! Overlapping + } + runs.append( + StyledRangeStoreRun( + length: highlight.range.length, + capture: highlight.capture, + modifiers: highlight.modifiers + ) + ) + lastIndex = highlight.range.max + } + + if lastIndex != rangeToHighlight.upperBound { + runs.append(.empty(length: rangeToHighlight.upperBound - lastIndex)) + } + + storage.set(runs: runs, for: rangeToHighlight.intRange) + delegate?.styleContainerDidUpdate(in: rangeToHighlight) + } +} 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..f5f278e5e --- /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. 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. + /// + /// - Parameter range: The range of the item to coalesce around. + func coalesceNearby(range: Range) { + var index = findIndex(at: range.lastIndex).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..3fe15a150 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore+StyledRun.swift @@ -0,0 +1,83 @@ +// +// 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: CaptureModifierSet + + 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 + } +} + +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..21d6bda4a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift @@ -0,0 +1,104 @@ +// +// 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 Run = StyledRangeStoreRun + 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: [])]) + } + + // 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") + 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 + } + + /// 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, + in: OffsetMetric(), + with: runs.map { StyledRun(length: $0.length, capture: $0.capture, modifiers: $0.modifiers) } + ) + + coalesceNearby(range: range) + cache = nil + } +} + +// 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") + + 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/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift new file mode 100644 index 000000000..06335edba --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStoreRun.swift @@ -0,0 +1,47 @@ +// +// StyledRangeStoreRun.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 11/4/24. +// + +/// Consumer-facing value type for the stored values in this container. +struct StyledRangeStoreRun: Equatable, Hashable { + var length: Int + var capture: CaptureName? + var modifiers: CaptureModifierSet + + static func empty(length: Int) -> Self { + StyledRangeStoreRun(length: length, capture: nil, modifiers: []) + } + + var isEmpty: Bool { + capture == nil && modifiers.isEmpty + } + + 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 StyledRangeStoreRun) { + self.capture = other.capture ?? self.capture + self.modifiers.formUnion(other.modifiers) + } + + mutating package func subtractLength(_ other: borrowing StyledRangeStoreRun) { + self.length -= other.length + } +} + +extension StyledRangeStoreRun: CustomDebugStringConvertible { + var debugDescription: String { + if isEmpty { + "\(length) (empty)" + } else { + "\(length) (\(capture.debugDescription), \(modifiers.values.debugDescription))" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift new file mode 100644 index 000000000..cc7938215 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift @@ -0,0 +1,88 @@ +// +// VisibleRangeProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 10/13/24. +// + +import AppKit +import CodeEditTextView + +@MainActor +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? + weak var delegate: VisibleRangeProviderDelegate? + + 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 + ) + } + } + + 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) + + delegate?.visibleSetDidUpdate(visibleSet) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index 040eb75a1..ecc03b22f 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -52,7 +52,9 @@ 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() { } // MARK: - Constants diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift new file mode 100644 index 000000000..d3f89f0cc --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlightProviderStateTest.swift @@ -0,0 +1,136 @@ +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 + ) { } +} + +final class HighlightProviderStateTest: XCTestCase { + var textView: TextView! + var rangeProvider: MockVisibleRangeProvider! + var delegate: EmptyHighlightProviderStateDelegate! + + @MainActor + 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( + 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) + } + + @MainActor + func test_setLanguage() { + 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) + } + + @MainActor + func test_storageUpdatedRangesPassedOn() { + 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/HighlighterTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift similarity index 58% rename from Tests/CodeEditSourceEditorTests/HighlighterTests.swift rename to Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift index 3998bfe72..fd33ddaaa 100644 --- a/Tests/CodeEditSourceEditorTests/HighlighterTests.swift +++ b/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift @@ -42,14 +42,12 @@ 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!") let highlighter = Mock.highlighter( textView: textView, highlightProvider: highlightProvider, - theme: theme, attributeProvider: attributeProvider ) @@ -61,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 + } } diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift new file mode 100644 index 000000000..1ea05fc20 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeContainerTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import CodeEditSourceEditor + +final class StyledRangeContainerTests: XCTestCase { + typealias Run = StyledRangeStoreRun + + @MainActor + func test_init() { + 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.sorted(), providers) + 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) + + 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) + XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[0].capture) + XCTAssertEqual(store._storage[providers[0]]!.runs(in: 0..<100)[1].capture, .comment) + XCTAssertNil(store._storage[providers[0]]!.runs(in: 0..<100)[2].capture) + + 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: []) + ] + ) + } + + @MainActor + 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: []) + ] + ) + } + + @MainActor + 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 new file mode 100644 index 000000000..0395e74b1 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/StyledRangeStoreTests.swift @@ -0,0 +1,229 @@ +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: [.static], 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) + + XCTAssertNil(runs[0].capture) + XCTAssertEqual(runs[1].capture, .comment) + XCTAssertNil(runs[2].capture) + + XCTAssertEqual(runs[0].modifiers, []) + XCTAssertEqual(runs[1].modifiers, [.static]) + XCTAssertEqual(runs[2].modifiers, []) + } + + func test_queryOverlappingRun() { + let store = StyledRangeStore(documentLength: 100) + store.set(capture: .comment, modifiers: [.static], 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) + XCTAssertNil(runs[1].capture) + + XCTAssertEqual(runs[0].modifiers, [.static]) + XCTAssertEqual(runs[1].modifiers, []) + } + + func test_setMultipleRuns() { + let store = StyledRangeStore(documentLength: 100) + + store.set(capture: .comment, modifiers: [.static], for: 5..<15) + store.set(capture: .keyword, modifiers: [], for: 20..<30) + 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) + + 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: [CaptureModifierSet] = [[], [.static], [], [], [], [.static], [], [], [], [], []] + + 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) + + 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: [CaptureModifierSet] = [[], [.static], [], [], [], [.static], [], [], [], [], []] + + 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) + + var runs = store.runs(in: 0..<100) + XCTAssertEqual(runs.count, 11) + XCTAssertEqual(runs.reduce(0, { $0 + $1.length }), 100) + + 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 = [[], [.static], [], [], [], [], [], [], []] + + runs.enumerated().forEach { + XCTAssertEqual($0.element.length, lengths[$0.offset]) + XCTAssertEqual($0.element.capture, captures[$0.offset]) + XCTAssertEqual($0.element.modifiers, modifiers[$0.offset]) + } + } +} diff --git a/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift new file mode 100644 index 000000000..e75098d85 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import CodeEditSourceEditor + +final class VisibleRangeProviderTests: XCTestCase { + @MainActor + 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) + } + + @MainActor + 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 + + @MainActor + 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..31c3e5377 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -1,8 +1,46 @@ import Foundation +import AppKit 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 { } @@ -19,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, @@ -64,6 +102,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,16 +123,22 @@ 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 ) } + + 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) }