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