diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 136b1e507..d4a0b1e16 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,14 +1,14 @@ name: 🐞 Bug report description: Something is not working as expected. -title: 🐞 YOUR_DESCRIPTION +title: 🐞 labels: bug body: - type: textarea attributes: label: Description - description: >- - A clear and concise description of what the bug is. + placeholder: >- + A clear and concise description of what the bug is... validations: required: true @@ -16,7 +16,7 @@ body: attributes: label: To Reproduce description: >- - Steps to reproduce the behavior. + Steps to reliably reproduce the behavior. placeholder: | 1. Go to '...' 2. Click on '....' @@ -27,26 +27,30 @@ body: - type: textarea attributes: - label: Expected behavior - description: >- - A clear and concise description of what you expected to happen. + label: Expected Behavior + placeholder: >- + A clear and concise description of what you expected to happen... validations: required: true - type: textarea attributes: - label: Version information + label: Version Information description: >- click on the version number on the welcome screen value: | - CodeEditTextView: [e.g. 1.0] - macOS: [e.g. 12.3.0] - Xcode: [e.g. 13.3] - validations: - required: true + CodeEditTextView: [e.g. 0.x.y] + macOS: [e.g. 13.2.1] + Xcode: [e.g. 14.2] + + - type: textarea + attributes: + label: Additional Context + placeholder: >- + Any other context or considerations about the bug... - type: textarea attributes: - label: Additional context - description: >- - Add any other context about the problem here. + label: Screenshots + placeholder: >- + If applicable, please provide relevant screenshots or screen recordings... diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d861e8d3f..476a7721e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,36 +1,31 @@ name: ✨ Feature request description: Suggest an idea for this project -title: ✨ YOUR_DESCRIPTION +title: ✨ labels: enhancement body: - - type: input - attributes: - label: Is your feature request related to a problem? Please describe. - placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - validations: - required: false - - type: textarea attributes: - label: Describe the solution you'd like + label: Description placeholder: >- - A clear and concise description of what you want to happen. + A clear and concise description of what you would like to happen... validations: required: true - type: textarea attributes: - label: Describe alternatives you've considered + label: Alternatives Considered placeholder: >- - A clear and concise description of any alternative solutions or features you've considered. - validations: - required: true + Any alternative solutions or features you've considered... - type: textarea attributes: - label: Additional context + label: Additional Context placeholder: >- - Add any other context or screenshots about the feature request here. - validations: - required: true + Any other context or considerations about the feature request... + + - type: textarea + attributes: + label: Screenshots + placeholder: >- + If applicable, please provide relevant screenshots or screen recordings... diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..d976c3fa5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ + + +### Description + + + +### Related Issues + + + + + +* #ISSUE_NUMBER + +### Checklist + + + +- [ ] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) +- [ ] The issues this PR addresses are related to each other +- [ ] My changes generate no new warnings +- [ ] My code builds and runs on my machine +- [ ] My changes are all related to the related issue above +- [ ] I documented my code + +### Screenshots + + + + diff --git a/Package.resolved b/Package.resolved index cf0b8e192..f58b470f3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", "state" : { - "revision" : "02595fb02841568b9befcbfcdaf9be88280c49aa", - "version" : "0.1.11" + "revision" : "08cb9dc04e70d1e7b9610580794ed4da774c2b84", + "version" : "0.1.13" } }, { diff --git a/Package.swift b/Package.swift index 5c1461071..41edcf0f8 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ), .package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", - exact: "0.1.11" + exact: "0.1.13" ), .package( url: "https://github.com/lukepistrol/SwiftLintPlugin", diff --git a/README.md b/README.md index 87061762f..b0f4e3fa3 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,13 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma -

CodeEdit

+

        CodeEdit        

-

CodeEditKit

+

     CodeEditKit     

@@ -103,7 +103,7 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma -

CodeEdit CLI

+

    CodeEdit CLI    

diff --git a/Sources/CodeEditTextView/CEScrollView.swift b/Sources/CodeEditTextView/CEScrollView.swift new file mode 100644 index 000000000..6e8dcc0c7 --- /dev/null +++ b/Sources/CodeEditTextView/CEScrollView.swift @@ -0,0 +1,28 @@ +// +// CEScrollView.swift +// +// +// Created by Renan Greca on 18/02/23. +// + +import AppKit +import STTextView + +class CEScrollView: NSScrollView { + + override func mouseDown(with event: NSEvent) { + + if let textView = self.documentView as? STTextView, + !textView.visibleRect.contains(event.locationInWindow) { + // If the `scrollView` was clicked, but the click did not happen within the `textView`, + // set cursor to the last index of the `textView`. + + let endLocation = textView.textLayoutManager.documentRange.endLocation + let range = NSTextRange(location: endLocation) + _ = textView.becomeFirstResponder() + textView.setSelectedRange(range) + } + + super.mouseDown(with: event) + } +} diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 2ff103072..7fe2f4268 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -17,9 +17,9 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - text: The text content /// - language: The language for syntax highlighting /// - theme: The theme for syntax highlighting - /// - useThemeBackground: Whether CodeEditTextView uses theme background color or is transparent /// - font: The default font - /// - tabWidth: The tab width + /// - tabWidth: The visual tab width in number of spaces + /// - indentOption: The option to use for indentation when the tab key is pressed. Defaults to 4 spaces /// - lineHeight: The line height multiplier (e.g. `1.2`) /// - wrapLines: Whether lines wrap to the width of the editor /// - editorOverscroll: The percentage for overscroll, between 0-1 (default: `0.0`) @@ -27,19 +27,22 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// built-in `TreeSitterClient` highlighter. /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. + /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. public init( _ text: Binding, language: CodeLanguage, theme: Binding, font: Binding, tabWidth: Binding, + indentOption: Binding = .constant(.string(count: 4)), lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), - cursorPosition: Published<(Int, Int)>.Publisher? = nil, + cursorPosition: Binding<(Int, Int)>, useThemeBackground: Bool = true, highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil + contentInsets: NSEdgeInsets? = nil, + isEditable: Bool = true ) { self._text = text self.language = language @@ -47,12 +50,14 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.useThemeBackground = useThemeBackground self._font = font self._tabWidth = tabWidth + self._indentOption = indentOption self._lineHeight = lineHeight self._wrapLines = wrapLines self._editorOverscroll = editorOverscroll - self.cursorPosition = cursorPosition + self._cursorPosition = cursorPosition self.highlightProvider = highlightProvider self.contentInsets = contentInsets + self.isEditable = isEditable } @Binding private var text: String @@ -60,13 +65,15 @@ public struct CodeEditTextView: NSViewControllerRepresentable { @Binding private var theme: EditorTheme @Binding private var font: NSFont @Binding private var tabWidth: Int + @Binding private var indentOption: IndentOption @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @Binding private var editorOverscroll: Double - private var cursorPosition: Published<(Int, Int)>.Publisher? + @Binding private var cursorPosition: (Int, Int) private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? + private var isEditable: Bool public typealias NSViewControllerType = STTextViewController @@ -77,12 +84,14 @@ public struct CodeEditTextView: NSViewControllerRepresentable { font: font, theme: theme, tabWidth: tabWidth, + indentOption: indentOption, wrapLines: wrapLines, - cursorPosition: cursorPosition, + cursorPosition: $cursorPosition, editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, highlightProvider: highlightProvider, - contentInsets: contentInsets + contentInsets: contentInsets, + isEditable: isEditable ) controller.lineHeightMultiple = lineHeight return controller @@ -90,7 +99,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable { public func updateNSViewController(_ controller: NSViewControllerType, context: Context) { controller.font = font - controller.tabWidth = tabWidth controller.wrapLines = wrapLines controller.useThemeBackground = useThemeBackground controller.lineHeightMultiple = lineHeight @@ -105,6 +113,16 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.theme = theme } + // Updating the tab width (will) reset the default paragraph style, needing a re-render. + if controller.tabWidth != tabWidth { + controller.tabWidth = tabWidth + } + + // Updating the indentation unit causes regeneration of text filters. + if controller.indentOption != indentOption { + controller.indentOption = indentOption + } + controller.reloadUI() return } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift new file mode 100644 index 000000000..84160cfba --- /dev/null +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift @@ -0,0 +1,103 @@ +// +// STTextViewController+Cursor.swift +// +// +// Created by Elias Wahl on 15.03.23. +// + +import Foundation +import AppKit + +extension STTextViewController { + func setCursorPosition(_ position: (Int, Int)) { + guard let provider = textView.textLayoutManager.textContentManager else { + return + } + + var (line, column) = position + let string = textView.string + if line > 0 { + if string.isEmpty { + // If the file is blank, automatically place the cursor in the first index. + let range = NSRange(string.startIndex.. Bool + in + var col = 1 + /// If the cursor is at the end of the document: + if textLayoutManager.offset(from: insertionPointLocation, to: documentEndLocation) == 0 { + /// If document is empty: + if textLayoutManager.offset(from: documentStartLocation, to: documentEndLocation) == 0 { + self.cursorPosition.wrappedValue = (1, 1) + return false + } + guard let cursorTextFragment = textLayoutManager.textLayoutFragment(for: textSegmentFrame.origin), + let cursorTextLineFragment = cursorTextFragment.textLineFragments.last + else { return false } + + col = cursorTextLineFragment.characterRange.length + 1 + if col == 1 { line += 1 } + } else { + guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation) + else { return false } + + /// +1, because we start with the first character with 1 + let tempCol = cursorTextLineFragment.characterIndex(for: textSegmentFrame.origin) + let result = tempCol.addingReportingOverflow(1) + + if !result.overflow { col = result.partialValue } + /// If cursor is at end of line add 1: + if cursorTextLineFragment.characterRange.length != 1 && + (cursorTextLineFragment.typographicBounds.width == (textSegmentFrame.maxX + 5.0)) { + col += 1 + } + + /// If cursor is at first character of line, the current line is not being included + if col == 1 { line += 1 } + } + + self.cursorPosition.wrappedValue = (line, col) + return false + } + } +} diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift similarity index 83% rename from Sources/CodeEditTextView/STTextViewController.swift rename to Sources/CodeEditTextView/Controller/STTextViewController.swift index a8a272824..50811b6dc 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -37,21 +37,35 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt /// Whether the code editor should use the theme background color or be transparent public var useThemeBackground: Bool - /// The number of spaces to use for a `tab '\t'` character + /// The number of visual spaces to use for a tab `'\t'` character public var tabWidth: Int + /// The configuration to use when the tab key is pressed. + /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. + public var indentOption: IndentOption { + didSet { + setUpTextFormation() + } + } + /// A multiplier for setting the line height. Defaults to `1.0` public var lineHeightMultiple: Double = 1.0 /// The font to use in the `textView` public var font: NSFont + /// The current cursor position e.g. (1, 1) + public var cursorPosition: Binding<(Int, Int)> + /// The editorOverscroll to use for the textView over scroll public var editorOverscroll: Double /// Whether lines wrap to the width of the editor public var wrapLines: Bool + /// Whether or not text view is editable by user + public var isEditable: Bool + /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] @@ -76,24 +90,28 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt font: NSFont, theme: EditorTheme, tabWidth: Int, + indentOption: IndentOption, wrapLines: Bool, - cursorPosition: Published<(Int, Int)>.Publisher? = nil, + cursorPosition: Binding<(Int, Int)>, editorOverscroll: Double, useThemeBackground: Bool, highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil + contentInsets: NSEdgeInsets? = nil, + isEditable: Bool ) { self.text = text self.language = language self.font = font self.theme = theme self.tabWidth = tabWidth + self.indentOption = indentOption self.wrapLines = wrapLines self.cursorPosition = cursorPosition self.editorOverscroll = editorOverscroll self.useThemeBackground = useThemeBackground self.highlightProvider = highlightProvider self.contentInsets = contentInsets + self.isEditable = isEditable super.init(nibName: nil, bundle: nil) } @@ -107,7 +125,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt public override func loadView() { textView = STTextView() - let scrollView = NSScrollView() + let scrollView = CEScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = true scrollView.documentView = textView @@ -123,6 +141,14 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt rulerView.drawSeparator = false rulerView.baselineOffset = baselineOffset rulerView.font = NSFont.monospacedDigitSystemFont(ofSize: 9.5, weight: .regular) + rulerView.selectedLineHighlightColor = theme.lineHighlight + rulerView.rulerInsets = STRulerInsets(leading: 20, trailing: 8) + + if self.isEditable == false { + rulerView.selectedLineTextColor = nil + rulerView.selectedLineHighlightColor = theme.background + } + scrollView.verticalRulerView = rulerView scrollView.rulersVisible = true @@ -135,11 +161,13 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt textView.selectionBackgroundColor = theme.selection textView.selectedLineHighlightColor = theme.lineHighlight textView.string = self.text.wrappedValue + textView.isEditable = self.isEditable textView.widthTracksTextView = self.wrapLines textView.highlightSelectedLine = true textView.allowsUndo = true textView.setupMenus() textView.delegate = self + textView.highlightSelectedLine = self.isEditable scrollView.documentView = textView @@ -163,9 +191,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt setHighlightProvider(self.highlightProvider) setUpTextFormation() - self.cursorPositionCancellable = self.cursorPosition?.sink(receiveValue: { value in - self.setCursorPosition(value) - }) + self.setCursorPosition(self.cursorPosition.wrappedValue) } public override func viewDidLoad() { @@ -177,13 +203,21 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt guard let self = self else { return } (self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets } + + NotificationCenter.default.addObserver( + forName: STTextView.didChangeSelectionNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateCursorPosition() + } } public override func viewDidAppear() { super.viewDidAppear() } - public func textDidChange(_ notification: Notification) { + public func textViewDidChangeText(_ notification: Notification) { self.text.wrappedValue = textView.string } @@ -195,6 +229,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight +// paragraph.tabStops.removeAll() +// paragraph.defaultTabInterval = CGFloat(tabWidth) * " ".size(withAttributes: [.font: self.font]).width return paragraph } @@ -223,10 +259,14 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt textView?.insertionPointColor = theme.insertionPoint textView?.selectionBackgroundColor = theme.selection textView?.selectedLineHighlightColor = theme.lineHighlight + textView?.isEditable = isEditable + textView.highlightSelectedLine = isEditable rulerView?.backgroundColor = useThemeBackground ? theme.background : .clear rulerView?.separatorColor = theme.invisibles + rulerView?.selectedLineHighlightColor = theme.lineHighlight rulerView?.baselineOffset = baselineOffset + rulerView.highlightSelectedLine = isEditable if let scrollView = view as? NSScrollView { scrollView.drawsBackground = useThemeBackground @@ -292,7 +332,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt return self?.textView.textContentStorage.textStorage?.mutableString.substring(with: range) } - provider = try? TreeSitterClient(codeLanguage: language, textProvider: textProvider) + provider = TreeSitterClient(codeLanguage: language, textProvider: textProvider) } if let provider = provider { @@ -308,45 +348,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt // TODO: - This should be uncessecary } - // MARK: Cursor Position - - private var cursorPosition: Published<(Int, Int)>.Publisher? - private var cursorPositionCancellable: AnyCancellable? - - private func setCursorPosition(_ position: (Int, Int)) { - guard let provider = textView.textLayoutManager.textContentManager else { - return - } - - var (line, column) = position - let string = textView.string - if line > 0 { - if string.isEmpty { - // If the file is blank, automatically place the cursor in the first index. - let range = NSRange(string.startIndex.. Bool { + switch (lhs, rhs) { + case (.tab, .tab): + return true + case (.string(let lhsCount), .string(let rhsCount)): + return lhsCount == rhsCount + default: + return false + } + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift new file mode 100644 index 000000000..15c7e3b12 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift @@ -0,0 +1,18 @@ +// +// NSRange+Comparable.swift +// +// +// 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/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift index 9994a92df..ee8018de5 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift @@ -29,3 +29,35 @@ extension InputEdit { newEndPoint: newEndPoint) } } + +extension NSRange { + // swiftlint:disable line_length + /// Modifies the range to account for an edit. + /// Largely based on code from + /// [tree-sitter](https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720) + mutating func applyInputEdit(_ edit: InputEdit) { + // swiftlint:enable line_length + let endIndex = NSMaxRange(self) + let isPureInsertion = edit.oldEndByte == edit.startByte + + // Edit is after the range + if (edit.startByte/2) > endIndex { + return + } else if edit.oldEndByte/2 < location { + // If the edit is entirely before this range + self.location += (Int(edit.newEndByte) - Int(edit.oldEndByte))/2 + } else if edit.startByte/2 < location { + // If the edit starts in the space before this range and extends into this range + length -= Int(edit.oldEndByte)/2 - location + location = Int(edit.newEndByte)/2 + } else if edit.startByte/2 == location && isPureInsertion { + // If the edit is *only* an insertion right at the beginning of the range + location = Int(edit.newEndByte)/2 + } else { + // Otherwise, the edit is entirely within this range + if edit.startByte/2 < endIndex || (edit.startByte/2 == endIndex && isPureInsertion) { + length = (Int(edit.newEndByte)/2 - location) + (length - (Int(edit.oldEndByte)/2 - location)) + } + } + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift new file mode 100644 index 000000000..a0273784e --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift @@ -0,0 +1,18 @@ +// +// NSRange+TSRange.swift +// +// +// Created by Khan Winter on 2/26/23. +// + +import Foundation +import SwiftTreeSitter + +extension NSRange { + var tsRange: TSRange { + return TSRange( + points: .zero..<(.zero), + bytes: (UInt32(self.location) * 2)..<(UInt32(self.location + self.length) * 2) + ) + } +} diff --git a/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift b/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift new file mode 100644 index 000000000..a021733c9 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift @@ -0,0 +1,71 @@ +// +// Tree+prettyPrint.swift +// +// +// Created by Khan Winter on 3/16/23. +// + +import SwiftTreeSitter + +#if DEBUG +extension Tree { + func prettyPrint() { + guard let cursor = self.rootNode?.treeCursor else { + print("NO ROOT NODE") + return + } + guard cursor.currentNode != nil else { + print("NO CURRENT NODE") + return + } + + func p(_ cursor: TreeCursor, depth: Int) { + guard let node = cursor.currentNode else { + return + } + + let visible = node.isNamed + + if visible { + print(String(repeating: " ", count: depth * 2), terminator: "") + if let fieldName = cursor.currentFieldName { + print(fieldName, ": ", separator: "", terminator: "") + } + print("(", node.nodeType ?? "NONE", " ", node.range, " ", separator: "", terminator: "") + } + + if cursor.goToFirstChild() { + while true { + if cursor.currentNode != nil && cursor.currentNode!.isNamed { + print("") + } + + p(cursor, depth: depth + 1) + + if !cursor.gotoNextSibling() { + break + } + } + + if !cursor.gotoParent() { + fatalError("Could not go to parent, this tree may be invalid.") + } + } + + if visible { + print(")", terminator: "") + } + } + + if cursor.currentNode?.childCount == 0 { + if !cursor.currentNode!.isNamed { + print("{\(cursor.currentNode!.nodeType ?? "NONE")}") + } else { + print("\"\(cursor.currentNode!.nodeType ?? "NONE")\"") + } + } else { + p(cursor, depth: 1) + } + } +} +#endif diff --git a/Sources/CodeEditTextView/Filters/NewlineFilter.swift b/Sources/CodeEditTextView/Filters/NewlineFilter.swift deleted file mode 100644 index b9ac913dc..000000000 --- a/Sources/CodeEditTextView/Filters/NewlineFilter.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// NewlineFilter.swift -// -// -// Created by Khan Winter on 1/28/23. -// - -import Foundation -import TextFormation -import TextStory - -/// A newline filter almost entirely similar to `TextFormation`s standard implementation. -struct NewlineFilter: Filter { - private let recognizer: ConsecutiveCharacterRecognizer - let providers: WhitespaceProviders - - init(whitespaceProviders: WhitespaceProviders) { - self.recognizer = ConsecutiveCharacterRecognizer(matching: "\n") - self.providers = whitespaceProviders - } - - func processMutation(_ mutation: TextStory.TextMutation, - in interface: TextFormation.TextInterface) -> TextFormation.FilterAction { - recognizer.processMutation(mutation) - - switch recognizer.state { - case .triggered: - return filterHandler(mutation, in: interface) - case .tracking, .idle: - return .none - } - } - - private func filterHandler(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - interface.applyMutation(mutation) - - let range = NSRange(location: mutation.postApplyRange.max, length: 0) - - let value = providers.leadingWhitespace(range, interface) - - interface.insertString(value, at: mutation.postApplyRange.max) - - return .discard - } -} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 55bba4a2e..1e84e810e 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -18,8 +18,6 @@ extension STTextViewController { internal func setUpTextFormation() { textFilters = [] - let indentationUnit = String(repeating: " ", count: tabWidth) - let pairsToHandle: [(String, String)] = [ ("{", "}"), ("[", "]"), @@ -29,18 +27,17 @@ extension STTextViewController { let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, - width: tabWidth), + leadingWhitespace: indenter.substitionProvider(indentationUnit: indentOption.stringValue, + width: indentOption.stringValue.count), trailingWhitespace: { _, _ in "" } ) // Filters setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) - setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indentationUnit) + setUpNewlineFilter(whitespaceProvider: whitespaceProvider) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) + setUpDeleteWhitespaceFilter(indentationUnit: indentOption.stringValue) } /// Returns a `TextualIndenter` based on available language configuration. @@ -67,14 +64,11 @@ extension STTextViewController { } /// Configures newline and tab replacement filters. - /// - Parameters: - /// - whitespaceProvider: The whitespace providers to use. - /// - indentationUnit: The unit of indentation to use. - private func setUpNewlineTabFilters(whitespaceProvider: WhitespaceProviders, indentationUnit: String) { - let newlineFilter: Filter = NewlineFilter(whitespaceProviders: whitespaceProvider) - let tabReplacementFilter: Filter = TabReplacementFilter(indentationUnit: indentationUnit) + /// - Parameter whitespaceProvider: The whitespace providers to use. + private func setUpNewlineFilter(whitespaceProvider: WhitespaceProviders) { + let newlineFilter: Filter = NewlineProcessingFilter(whitespaceProviders: whitespaceProvider) - textFilters.append(contentsOf: [newlineFilter, tabReplacementFilter]) + textFilters.append(contentsOf: [newlineFilter]) } /// Configures delete pair filters. diff --git a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift b/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift deleted file mode 100644 index c8db1f4b7..000000000 --- a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TabReplacementFilter.swift -// -// -// Created by Khan Winter on 1/28/23. -// - -import Foundation -import TextFormation -import TextStory - -/// Filter for replacing tab characters with the user-defined indentation unit. -/// - Note: The undentation unit can be another tab character, this is merely a point at which this can be configured. -struct TabReplacementFilter: Filter { - let indentationUnit: String - - func processMutation(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - if mutation.string == "\t" { - interface.applyMutation(TextMutation(insert: indentationUnit, - at: mutation.range.location, - limit: mutation.limit)) - return .discard - } else { - return .none - } - } -} diff --git a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift index cd376ef9f..199c2cc42 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift +++ b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift @@ -17,6 +17,9 @@ public protocol HighlightProviding { /// - Note: This does not need to be *globally* unique, merely unique across all the highlighters used. var identifier: String { get } + /// Called once at editor initialization. + func setUp(textView: HighlighterTextView) + /// Updates the highlighter's code language. /// - Parameters: /// - codeLanguage: The langugage that should be used by the highlighter. diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift index fe4990bca..ebda49b97 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift @@ -88,6 +88,7 @@ class Highlighter: NSObject { } textView.textContentStorage.textStorage?.delegate = self + highlightProvider?.setUp(textView: textView) if let scrollView = textView.enclosingScrollView { NotificationCenter.default.addObserver(self, @@ -121,6 +122,7 @@ class Highlighter: NSObject { public func setHighlightProvider(_ provider: HighlightProviding) { self.highlightProvider = provider highlightProvider?.setLanguage(codeLanguage: language) + highlightProvider?.setUp(textView: textView) invalidate() } @@ -282,7 +284,7 @@ extension Highlighter: NSTextStorageDelegate { delta: delta) { [weak self] invalidatedIndexSet in let indexSet = invalidatedIndexSet .union(IndexSet(integersIn: editedRange)) - // Only invalidate indices that aren't visible. + // Only invalidate indices that are visible. .intersection(self?.visibleSet ?? .init()) for range in indexSet.rangeView { diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift new file mode 100644 index 000000000..b7a31329e --- /dev/null +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift @@ -0,0 +1,163 @@ +// +// TreeSitterClient+Edit.swift +// +// +// Created by Khan Winter on 3/10/23. +// + +import Foundation +import SwiftTreeSitter +import CodeEditLanguages + +extension TreeSitterClient { + + /// Calculates a series of ranges that have been invalidated by a given edit. + /// - Parameters: + /// - textView: The text view to use for text. + /// - edit: The edit to act on. + /// - language: The language to use. + /// - readBlock: A callback for fetching blocks of text. + /// - Returns: An array of distinct `NSRanges` that need to be re-highlighted. + func findChangedByteRanges( + textView: HighlighterTextView, + edit: InputEdit, + layer: LanguageLayer, + readBlock: @escaping Parser.ReadBlock + ) -> [NSRange] { + let (oldTree, newTree) = calculateNewState( + tree: layer.tree, + parser: layer.parser, + edit: edit, + readBlock: readBlock + ) + if oldTree == nil && newTree == nil { + // There was no existing tree, make a new one and return all indexes. + layer.tree = createTree(parser: layer.parser, readBlock: readBlock) + return [NSRange(textView.documentRange.intRange)] + } + + let ranges = changedByteRanges(oldTree, rhs: newTree).map { $0.range } + + layer.tree = newTree + + return ranges + } + + /// Applies the edit to the current `tree` and returns the old tree and a copy of the current tree with the + /// processed edit. + /// - Parameters: + /// - tree: The tree before an edit used to parse the new tree. + /// - parser: The parser used to parse the new tree. + /// - edit: The edit to apply. + /// - readBlock: The block to use to read text. + /// - Returns: (The old state, the new state). + internal func calculateNewState( + tree: Tree?, + parser: Parser, + edit: InputEdit, + readBlock: @escaping Parser.ReadBlock + ) -> (Tree?, Tree?) { + guard let oldTree = tree else { + return (nil, nil) + } + semaphore.wait() + + // Apply the edit to the old tree + oldTree.edit(edit) + + let newTree = parser.parse(tree: oldTree, readBlock: readBlock) + + semaphore.signal() + + return (oldTree.copy(), newTree) + } + + /// Calculates the changed byte ranges between two trees. + /// - Parameters: + /// - lhs: The first (older) tree. + /// - rhs: The second (newer) tree. + /// - Returns: Any changed ranges. + internal func changedByteRanges(_ lhs: Tree?, rhs: Tree?) -> [Range] { + switch (lhs, rhs) { + case (let t1?, let t2?): + return t1.changedRanges(from: t2).map({ $0.bytes }) + case (nil, let t2?): + let range = t2.rootNode?.byteRange + + return range.flatMap({ [$0] }) ?? [] + case (_, nil): + return [] + } + } + + /// Performs an injections query on the given language layer. + /// Updates any existing layers with new ranges and adds new layers if needed. + /// - Parameters: + /// - textView: The text view to use. + /// - layer: The language layer to perform the query on. + /// - layerSet: The set of layers that exist in the document. + /// Used for efficient lookup of existing `(language, range)` pairs + /// - touchedLayers: The set of layers that existed before updating injected layers. + /// Will have items removed as they are found. + /// - readBlock: A completion block for reading from text storage efficiently. + /// - Returns: An index set of any updated indexes. + @discardableResult + internal func updateInjectedLanguageLayers( + textView: HighlighterTextView, + layer: LanguageLayer, + layerSet: inout Set, + touchedLayers: inout Set, + readBlock: @escaping Parser.ReadBlock + ) -> IndexSet { + guard let tree = layer.tree, + let rootNode = tree.rootNode, + let cursor = layer.languageQuery?.execute(node: rootNode, in: tree) else { + return IndexSet() + } + + cursor.matchLimit = Constants.treeSitterMatchLimit + + let languageRanges = self.injectedLanguagesFrom(cursor: cursor) { range, _ in + return textView.stringForRange(range) + } + + var updatedRanges = IndexSet() + + for (languageName, ranges) in languageRanges { + guard let treeSitterLanguage = TreeSitterLanguage(rawValue: languageName) else { + continue + } + + if treeSitterLanguage == primaryLayer { + continue + } + + for range in ranges { + // Temp layer object for + let layer = LanguageLayer( + id: treeSitterLanguage, + parser: Parser(), + supportsInjections: false, + ranges: [range.range] + ) + + if layerSet.contains(layer) { + // If we've found this layer, it means it should exist after an edit. + touchedLayers.remove(layer) + } else { + // New range, make a new layer! + if let addedLayer = addLanguageLayer(layerId: treeSitterLanguage, readBlock: readBlock) { + addedLayer.ranges = [range.range] + addedLayer.parser.includedRanges = addedLayer.ranges.map { $0.tsRange } + addedLayer.tree = createTree(parser: addedLayer.parser, readBlock: readBlock) + + layerSet.insert(addedLayer) + updatedRanges.insert(range: range.range) + } + } + } + } + + return updatedRanges + } +} diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift new file mode 100644 index 000000000..de8764f0a --- /dev/null +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift @@ -0,0 +1,92 @@ +// +// TreeSitterClient+Highlight.swift +// +// +// Created by Khan Winter on 3/10/23. +// + +import Foundation +import SwiftTreeSitter +import CodeEditLanguages + +extension TreeSitterClient { + + /// Queries the given language layer for any highlights. + /// - Parameters: + /// - layer: The layer to query. + /// - textView: A text view to use for contextual data. + /// - range: The range to query for. + /// - Returns: Any ranges to highlight. + internal func queryLayerHighlights( + layer: LanguageLayer, + textView: HighlighterTextView, + range: NSRange + ) -> [HighlightRange] { + // Make sure we don't change the tree while we copy it. + self.semaphore.wait() + + guard let tree = layer.tree?.copy() else { + self.semaphore.signal() + return [] + } + + self.semaphore.signal() + + guard let rootNode = tree.rootNode else { + return [] + } + + // This needs to be on the main thread since we're going to use the `textProvider` in + // the `highlightsFromCursor` method, which uses the textView's text storage. + guard let cursor = layer.languageQuery?.execute(node: rootNode, in: tree) else { + return [] + } + cursor.setRange(range) + cursor.matchLimit = Constants.treeSitterMatchLimit + + return highlightsFromCursor(cursor: ResolvingQueryCursor(cursor: cursor)) + } + + /// Resolves a query cursor to the highlight ranges it contains. + /// **Must be called on the main thread** + /// - Parameter cursor: The cursor to resolve. + /// - Returns: Any highlight ranges contained in the cursor. + internal func highlightsFromCursor(cursor: ResolvingQueryCursor) -> [HighlightRange] { + cursor.prepare(with: self.textProvider) + return cursor + .flatMap { $0.captures } + .compactMap { + // Some languages add an "@spell" capture to indicate a portion of text that should be spellchecked + // (usually comments). But this causes other captures in the same range to be overriden. So we ignore + // that specific capture type. + if $0.name != "spell" && $0.name != "injection.content" { + return HighlightRange(range: $0.range, capture: CaptureName.fromString($0.name ?? "")) + } + return nil + } + } + + /// Returns all injected languages from a given cursor. The cursor must be new, + /// having not been used for normal highlight matching. + /// - Parameters: + /// - cursor: The cursor to use for finding injected languages. + /// - textProvider: A callback for efficiently fetching text. + /// - Returns: A map of each language to all the ranges they have been injected into. + internal func injectedLanguagesFrom( + cursor: QueryCursor, + textProvider: @escaping ResolvingQueryCursor.TextProvider + ) -> [String: [NamedRange]] { + var languages: [String: [NamedRange]] = [:] + + for match in cursor { + if let injection = match.injection(with: textProvider) { + if languages[injection.name] == nil { + languages[injection.name] = [] + } + languages[injection.name]?.append(injection) + } + } + + return languages + } +} diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift new file mode 100644 index 000000000..c5ab2cc15 --- /dev/null +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift @@ -0,0 +1,54 @@ +// +// TreeSitterClient+LanguageLayer.swift +// +// +// Created by Khan Winter on 3/8/23. +// + +import Foundation +import CodeEditLanguages +import SwiftTreeSitter + +extension TreeSitterClient { + class LanguageLayer: Hashable { + /// Initialize a language layer + /// - Parameters: + /// - id: The ID of the layer. + /// - parser: A parser to use for the layer. + /// - supportsInjections: Set to true when the langauge supports the `injections` query. + /// - tree: The tree-sitter tree generated while editing/parsing a document. + /// - languageQuery: The language query used for fetching the associated `queries.scm` file + /// - ranges: All ranges this layer acts on. Must be kept in order and w/o overlap. + init( + id: TreeSitterLanguage, + parser: Parser, + supportsInjections: Bool, + tree: Tree? = nil, + languageQuery: Query? = nil, + ranges: [NSRange] + ) { + self.id = id + self.parser = parser + self.supportsInjections = supportsInjections + self.tree = tree + self.languageQuery = languageQuery + self.ranges = ranges + } + + let id: TreeSitterLanguage + let parser: Parser + let supportsInjections: Bool + var tree: Tree? + var languageQuery: Query? + var ranges: [NSRange] + + static func == (lhs: TreeSitterClient.LanguageLayer, rhs: TreeSitterClient.LanguageLayer) -> Bool { + return lhs.id == rhs.id && lhs.ranges == rhs.ranges + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(ranges) + } + } +} diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift index eea44f160..66276576f 100644 --- a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift @@ -18,46 +18,91 @@ import SwiftTreeSitter /// However, the `setText` method will re-compile the entire corpus so should be used sparingly. final class TreeSitterClient: HighlightProviding { + // MARK: - Properties/Constants + public var identifier: String { "CodeEdit.TreeSitterClient" } - internal var parser: Parser - internal var tree: Tree? - internal var languageQuery: Query? - - private var textProvider: ResolvingQueryCursor.TextProvider + internal var primaryLayer: TreeSitterLanguage + internal var layers: [LanguageLayer] = [] - /// The queue to do tree-sitter work on for large edits/queries - private let queue: DispatchQueue = DispatchQueue(label: "CodeEdit.CodeEditTextView.TreeSitter", - qos: .userInteractive) + internal var textProvider: ResolvingQueryCursor.TextProvider /// Used to ensure safe use of the shared tree-sitter tree state in different sync/async contexts. - private let semaphore: DispatchSemaphore = DispatchSemaphore(value: 1) + internal let semaphore: DispatchSemaphore = DispatchSemaphore(value: 1) + + internal enum Constants { + /// The maximum amount of limits a cursor can match during a query. + /// Used to ensure performance in large files, even though we generally limit the query to the visible range. + /// Neovim encountered this issue and uses 64 for their limit. Helix uses 256 due to issues with some + /// languages when using 64. + /// See: https://github.com/neovim/neovim/issues/14897 + /// And: https://github.com/helix-editor/helix/pull/4830 + static let treeSitterMatchLimit = 256 + } + + // MARK: - Init/Config /// Initializes the `TreeSitterClient` with the given parameters. /// - Parameters: /// - codeLanguage: The language to set up the parser with. /// - textProvider: The text provider callback to read any text. - public init(codeLanguage: CodeLanguage, textProvider: @escaping ResolvingQueryCursor.TextProvider) throws { - parser = Parser() - languageQuery = TreeSitterModel.shared.query(for: codeLanguage.id) - tree = nil - + public init(codeLanguage: CodeLanguage, textProvider: @escaping ResolvingQueryCursor.TextProvider) { self.textProvider = textProvider + self.primaryLayer = codeLanguage.id + setLanguage(codeLanguage: codeLanguage) + } + + /// Sets the primary language for the client. Will reset all layers, will not do any parsing work. + /// - Parameter codeLanguage: The new primary language. + public func setLanguage(codeLanguage: CodeLanguage) { + // Remove all trees and languages, everything needs to be re-parsed. + layers.removeAll() + + primaryLayer = codeLanguage.id + layers = [ + LanguageLayer( + id: codeLanguage.id, + parser: Parser(), + supportsInjections: codeLanguage.additionalHighlights?.contains("injections") ?? false, + tree: nil, + languageQuery: TreeSitterModel.shared.query(for: codeLanguage.id), + ranges: [] + ) + ] if let treeSitterLanguage = codeLanguage.language { - try parser.setLanguage(treeSitterLanguage) + try? layers[0].parser.setLanguage(treeSitterLanguage) } } - func setLanguage(codeLanguage: CodeLanguage) { - if let treeSitterLanguage = codeLanguage.language { - try? parser.setLanguage(treeSitterLanguage) - } + // MARK: - HighlightProviding + + /// Set up and parse the initial language tree and all injected layers. + func setUp(textView: HighlighterTextView) { + let readBlock = createReadBlock(textView: textView) + + layers[0].tree = createTree( + parser: layers[0].parser, + readBlock: readBlock + ) + + var layerSet = Set(arrayLiteral: layers[0]) + var touchedLayers = Set() + + var idx = 0 + while idx < layers.count { + updateInjectedLanguageLayers( + textView: textView, + layer: layers[idx], + layerSet: &layerSet, + touchedLayers: &touchedLayers, + readBlock: readBlock + ) - // Get rid of the current tree, it needs to be re-parsed. - tree = nil + idx += 1 + } } /// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted. @@ -67,151 +112,179 @@ final class TreeSitterClient: HighlightProviding { /// - range: The range of the edit. /// - delta: The length of the edit, can be negative for deletions. /// - completion: The function to call with an `IndexSet` containing all Indices to invalidate. - func applyEdit(textView: HighlighterTextView, - range: NSRange, - delta: Int, - completion: @escaping ((IndexSet) -> Void)) { - guard let edit = InputEdit(range: range, delta: delta, oldEndPoint: .zero) else { - return - } + func applyEdit( + textView: HighlighterTextView, + range: NSRange, + delta: Int, + completion: @escaping ((IndexSet) -> Void) + ) { + guard let edit = InputEdit(range: range, delta: delta, oldEndPoint: .zero) else { return } + let readBlock = createReadBlock(textView: textView) + var rangeSet = IndexSet() - let readFunction: Parser.ReadBlock = { byteOffset, _ in - let limit = textView.documentRange.length - let location = byteOffset / 2 - let end = min(location + (1024), limit) - if location > end { - assertionFailure("location is greater than end") - return nil + // Helper data structure for finding existing layers in O(1) when adding injected layers + var layerSet = Set(minimumCapacity: layers.count) + // Tracks which layers were not touched at some point during the edit. Any layers left in this set + // after the second loop are removed. + var touchedLayers = Set(minimumCapacity: layers.count) + + // Loop through all layers, apply edits & find changed byte ranges. + for layerIdx in (0.. Void)) { - // Make sure we dont accidentally change the tree while we copy it. - self.semaphore.wait() - guard let tree = self.tree?.copy() else { - // In this case, we don't have a tree to work with already, so we need to make it and try to - // return some highlights - createTree(textView: textView) - - // This is slightly redundant but we're only doing one check. - guard let treeRetry = self.tree?.copy() else { - // Now we can return nothing for real. - self.semaphore.signal() - completion([]) - return + // Loop again and apply injections query, add any ranges not previously found + // using while loop because `updateInjectedLanguageLayers` can add to `layers` during the loop + var idx = 0 + while idx < layers.count { + let layer = layers[idx] + + if layer.supportsInjections { + rangeSet.formUnion( + updateInjectedLanguageLayers( + textView: textView, + layer: layer, + layerSet: &layerSet, + touchedLayers: &touchedLayers, + readBlock: readBlock + ) + ) } - self.semaphore.signal() - _queryColorsFor(tree: treeRetry, range: range, completion: completion) - return + idx += 1 } - self.semaphore.signal() + // Delete any layers that weren't touched at some point during the edit. + layers.removeAll(where: { touchedLayers.contains($0) }) - _queryColorsFor(tree: tree, range: range, completion: completion) + completion(rangeSet) } - private func _queryColorsFor(tree: Tree, - range: NSRange, - completion: @escaping (([HighlightRange]) -> Void)) { - guard let rootNode = tree.rootNode else { - completion([]) - return + /// Initiates a highlight query. + /// - Parameters: + /// - textView: The text view to use. + /// - range: The range to limit the highlights to. + /// - completion: Called when the query completes. + func queryHighlightsFor( + textView: HighlighterTextView, + range: NSRange, + completion: @escaping ((([HighlightRange]) -> Void)) + ) { + var highlights: [HighlightRange] = [] + var injectedSet = IndexSet(integersIn: range) + + for layer in layers where layer.id != primaryLayer { + // Query injected only if a layer's ranges intersects with `range` + for layerRange in layer.ranges { + if let rangeIntersection = range.intersection(layerRange) { + highlights.append(contentsOf: queryLayerHighlights( + layer: layer, + textView: textView, + range: rangeIntersection + )) + + injectedSet.remove(integersIn: rangeIntersection) + } + } } - // This needs to be on the main thread since we're going to use the `textProvider` in - // the `highlightsFromCursor` method, which uses the textView's text storage. - guard let cursor = self.languageQuery?.execute(node: rootNode, in: tree) else { - completion([]) - return + // Query primary for any ranges that weren't used in the injected layers. + for range in injectedSet.rangeView { + highlights.append(contentsOf: queryLayerHighlights( + layer: layers[0], + textView: textView, + range: NSRange(range) + )) } - cursor.setRange(range) - - let highlights = self.highlightsFromCursor(cursor: ResolvingQueryCursor(cursor: cursor)) completion(highlights) } - /// Creates a tree. - /// - Parameter textView: The text provider to use. - private func createTree(textView: HighlighterTextView) { - self.tree = self.parser.parse(textView.stringForRange(textView.documentRange) ?? "") - } - - /// Resolves a query cursor to the highlight ranges it contains. - /// **Must be called on the main thread** - /// - Parameter cursor: The cursor to resolve. - /// - Returns: Any highlight ranges contained in the cursor. - private func highlightsFromCursor(cursor: ResolvingQueryCursor) -> [HighlightRange] { - cursor.prepare(with: self.textProvider) - return cursor - .flatMap { $0.captures } - .map { HighlightRange(range: $0.range, capture: CaptureName.fromString($0.name ?? "")) } - } -} + // MARK: - Helpers -extension TreeSitterClient { - /// Applies the edit to the current `tree` and returns the old tree and a copy of the current tree with the - /// processed edit. + /// Attempts to create a language layer and load a highlights file. + /// Adds the layer to the `layers` array if successful. /// - Parameters: - /// - edit: The edit to apply. - /// - readBlock: The block to use to read text. - /// - Returns: (The old state, the new state). - private func calculateNewState(edit: InputEdit, - readBlock: @escaping Parser.ReadBlock) -> (Tree?, Tree?) { - guard let oldTree = self.tree else { - return (nil, nil) + /// - layerId: A language ID to add as a layer. + /// - readBlock: Completion called for efficient string lookup. + internal func addLanguageLayer( + layerId: TreeSitterLanguage, + readBlock: @escaping Parser.ReadBlock + ) -> LanguageLayer? { + guard let language = CodeLanguage.allLanguages.first(where: { $0.id == layerId }), + let parserLanguage = language.language + else { + return nil } - self.semaphore.wait() - // Apply the edit to the old tree - oldTree.edit(edit) - - self.tree = self.parser.parse(tree: oldTree, readBlock: readBlock) - - self.semaphore.signal() + let newLayer = LanguageLayer( + id: layerId, + parser: Parser(), + supportsInjections: language.additionalHighlights?.contains("injections") ?? false, + tree: nil, + languageQuery: TreeSitterModel.shared.query(for: layerId), + ranges: [] + ) + + do { + try newLayer.parser.setLanguage(parserLanguage) + } catch { + return nil + } - return (oldTree.copy(), self.tree?.copy()) + layers.append(newLayer) + return newLayer } - /// Calculates the changed byte ranges between two trees. + /// Creates a tree-sitter tree. /// - Parameters: - /// - lhs: The first (older) tree. - /// - rhs: The second (newer) tree. - /// - Returns: Any changed ranges. - private func changedByteRanges(_ lhs: Tree?, rhs: Tree?) -> [Range] { - switch (lhs, rhs) { - case (let t1?, let t2?): - return t1.changedRanges(from: t2).map({ $0.bytes }) - case (nil, let t2?): - let range = t2.rootNode?.byteRange - - return range.flatMap({ [$0] }) ?? [] - case (_, nil): - return [] + /// - parser: The parser object to use to parse text. + /// - readBlock: A callback for fetching blocks of text. + /// - Returns: A tree if it could be parsed. + internal func createTree(parser: Parser, readBlock: @escaping Parser.ReadBlock) -> Tree? { + return parser.parse(tree: nil, readBlock: readBlock) + } + + internal func createReadBlock(textView: HighlighterTextView) -> Parser.ReadBlock { + return { byteOffset, _ in + let limit = textView.documentRange.length + let location = byteOffset / 2 + let end = min(location + (1024), limit) + if location > end { + // Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations. + return nil + } + let range = NSRange(location..