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)
}