Skip to content

Commit 2a39fd0

Browse files
Async TreeSitterClient (#191)
### Description Modifies `TreeSitterClient` to support doing work on a background thread when doing so is beneficial. This allows the editor to display content while languages load, scroll smoothly while highlight queries perform in the background, and on large documents perform edits asynchronously to keep UI responsive. Edits and highlights are only queued for background work when: - An edit takes too long (`Constants.parserTimeout`) and a parser times out. - An edit or highlight is too large (`Constants.maxSyncEditLength`, `Constants.maxSyncQueryLength`) - The document is large enough to make tree-sitter slow no matter what (`Constants.maxSyncContentLength`) - There are async jobs already being performed. Changes: - Created a `TreeSitterState` object that handles managing the language layer tree (language layers renamed to `LanguageLayer`) - Handles setting and resetting languages - Manages injection layers. - Added `PthreadLock` for efficient, thread-safe locking via a wrapped `pthread_mutex`. - **TreeSitterClient** - Simplified TreeSitterClient.swift to only contain async methods, initialization and configuration, and HighlightProviding conformance code. - Created async versions of `applyEdit` and `queryHighlightsFor` - Added two queues: `queuedEdits` and `queuedQueries` to hold any enqueued edits and queries. - Added two locks: - `stateLock`: Must be acquired before performing any actions on the state object - `queueLock`: Must be acquired before modifying the previously mentioned queues. - Added private methods for starting and stopping the background task in `runningTask`: - `beginTasksIfNeeded` starts the background task if there is any outstanding work and there is no existing task. - `determineNextJob` dequeues a job(s) to perform in the background task. Handles job ordering and greedily dequeues highlight jobs as determined by `Constants.simultaneousHighlightLimit` - `cancelAllRunningTasks` cancels the running task and removes all queued jobs. ### Related Issues - #132 - This PR is half of this issue. The other half has to do with text editing performance which this PR does not change. ### Checklist <!--- Add things that are not yet implemented above --> - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Async language loading. Swift has a terrible initial loading time and this recording shows that the document now opens instantly but performs the initial work on a background thread. *Note that this initial loading time is nearly nonexistent in subsequent document loads due to the queries being cached.* Previously, this would have blocked the main thread for the entire duration of the language load (up to 5s in my testing). https://github.com/CodeEditApp/CodeEditTextView/assets/35942988/d7feb9ae-0846-4ad3-8598-17f7b6d753e0 Async highlighting means extremely long documents can load and highlight without affecting displaying and scrolling. This is still slightly "hitch"-y due to NSTextStorage performance when applying highlight data to the text view. https://github.com/CodeEditApp/CodeEditTextView/assets/35942988/affcaf41-d9db-4210-80f1-cb972c5060f2
1 parent 1dfd0b5 commit 2a39fd0

19 files changed

+972
-477
lines changed

Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ let package = Package(
1919
),
2020
.package(
2121
url: "https://github.com/CodeEditApp/CodeEditLanguages.git",
22-
exact: "0.1.13"
22+
exact: "0.1.14"
2323
),
2424
.package(
2525
url: "https://github.com/lukepistrol/SwiftLintPlugin",

Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ extension STTextViewController {
3131
return self?.textView.textContentStorage?.textStorage?.mutableString.substring(with: range)
3232
}
3333

34-
provider = TreeSitterClient(codeLanguage: language, textProvider: textProvider)
34+
provider = TreeSitterClient(textProvider: textProvider)
3535
}
3636

3737
if let provider = provider {

Sources/CodeEditTextView/Enums/CaptureName.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public enum CaptureName: String, CaseIterable {
3333
/// - Parameter string: A string to get the capture name from
3434
/// - Returns: A `CaptureNames` case
3535
static func fromString(_ string: String?) -> CaptureName? {
36-
allCases.first { $0.rawValue == string }
36+
CaptureName(rawValue: string ?? "")
3737
}
3838

3939
var alternate: CaptureName {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// HighlighterTextView+createReadBlock.swift
3+
//
4+
//
5+
// Created by Khan Winter on 5/20/23.
6+
//
7+
8+
import Foundation
9+
import SwiftTreeSitter
10+
11+
extension HighlighterTextView {
12+
func createReadBlock() -> Parser.ReadBlock {
13+
return { byteOffset, _ in
14+
let limit = self.documentRange.length
15+
let location = byteOffset / 2
16+
let end = min(location + (1024), limit)
17+
if location > end {
18+
// Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations.
19+
return nil
20+
}
21+
let range = NSRange(location..<end)
22+
return self.stringForRange(range)?.data(using: String.nativeUTF16Encoding)
23+
}
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// Parser+createTree.swift
3+
//
4+
//
5+
// Created by Khan Winter on 5/20/23.
6+
//
7+
8+
import Foundation
9+
import SwiftTreeSitter
10+
11+
extension Parser {
12+
/// Creates a tree-sitter tree.
13+
/// - Parameters:
14+
/// - parser: The parser object to use to parse text.
15+
/// - readBlock: A callback for fetching blocks of text.
16+
/// - Returns: A tree if it could be parsed.
17+
internal func createTree(readBlock: @escaping Parser.ReadBlock) -> Tree? {
18+
return parse(tree: nil, readBlock: readBlock)
19+
}
20+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// STTextView+HighlighterTextView.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 6/2/23.
6+
//
7+
8+
import Foundation
9+
import STTextView
10+
11+
/// A default implementation for `STTextView` to be passed to `HighlightProviding` objects.
12+
extension STTextView: HighlighterTextView {
13+
public var documentRange: NSRange {
14+
return NSRange(
15+
location: 0,
16+
length: textContentStorage?.textStorage?.length ?? 0
17+
)
18+
}
19+
20+
public func stringForRange(_ nsRange: NSRange) -> String? {
21+
return textContentStorage?.textStorage?.mutableString.substring(with: nsRange)
22+
}
23+
}

Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,43 @@ extension STTextView {
2121

2222
// Get visible rect
2323
guard let bounds = enclosingScrollView?.documentVisibleRect else {
24-
return textLayoutManager.documentRange.nsRange(using: textContentStorage)
24+
return nil
2525
}
2626

2727
// Calculate min & max points w/ a small amount of padding vertically.
28-
let minPoint = CGPoint(x: bounds.minX,
29-
y: bounds.minY - 100)
30-
let maxPoint = CGPoint(x: bounds.maxX,
31-
y: bounds.maxY + 100)
28+
let minPoint = CGPoint(
29+
x: bounds.minX,
30+
y: bounds.minY - 200
31+
)
32+
let maxPoint = CGPoint(
33+
x: bounds.maxX,
34+
y: bounds.maxY + 200
35+
)
3236

3337
// Get text fragments for both the min and max points
34-
guard let start = textLayoutManager.textLayoutFragment(for: minPoint)?.rangeInElement.location,
35-
let end = textLayoutManager.textLayoutFragment(for: maxPoint)?.rangeInElement.endLocation else {
36-
return textLayoutManager.documentRange.nsRange(using: textContentStorage)
38+
guard let start = textLayoutManager.textLayoutFragment(for: minPoint)?.rangeInElement.location else {
39+
return nil
3740
}
41+
42+
// End point can be tricky sometimes. If the document is smaller than the scroll view it can sometimes return
43+
// nil for the `maxPoint` layout fragment. So we attempt to grab the last fragment.
44+
var end: NSTextLocation?
45+
46+
if let endFragment = textLayoutManager.textLayoutFragment(for: maxPoint) {
47+
end = endFragment.rangeInElement.location
48+
} else {
49+
textLayoutManager.ensureLayout(for: NSTextRange(location: textLayoutManager.documentRange.endLocation))
50+
textLayoutManager.enumerateTextLayoutFragments(
51+
from: textLayoutManager.documentRange.endLocation,
52+
options: [.reverse, .ensuresLayout, .ensuresExtraLineFragment]
53+
) { layoutFragment in
54+
end = layoutFragment.rangeInElement.endLocation
55+
return false
56+
}
57+
}
58+
59+
guard let end else { return nil }
60+
3861
guard start.compare(end) != .orderedDescending else {
3962
return NSTextRange(location: end, end: start)?.nsRange(using: textContentStorage)
4063
}

Sources/CodeEditTextView/Highlighting/HighlightProviding.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,17 @@ import STTextView
1111
import AppKit
1212

1313
/// The protocol a class must conform to to be used for highlighting.
14-
public protocol HighlightProviding {
14+
public protocol HighlightProviding: AnyObject {
1515
/// A unique identifier for the highlighter object.
1616
/// Example: `"CodeEdit.TreeSitterHighlighter"`
1717
/// - Note: This does not need to be *globally* unique, merely unique across all the highlighters used.
1818
var identifier: String { get }
1919

20-
/// Called once at editor initialization.
21-
func setUp(textView: HighlighterTextView)
22-
23-
/// Updates the highlighter's code language.
20+
/// Called once to set up the highlight provider with a data source and language.
2421
/// - Parameters:
22+
/// - textView: The text view to use as a text source.
2523
/// - codeLanguage: The langugage that should be used by the highlighter.
26-
func setLanguage(codeLanguage: CodeLanguage)
24+
func setUp(textView: HighlighterTextView, codeLanguage: CodeLanguage)
2725

2826
/// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted.
2927
/// The returned `IndexSet` should include all indexes that need to be highlighted, including any inserted text.

Sources/CodeEditTextView/Highlighting/Highlighter.swift

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ class Highlighter: NSObject {
6767
/// - textView: The text view to highlight.
6868
/// - treeSitterClient: The tree-sitter client to handle tree updates and highlight queries.
6969
/// - theme: The theme to use for highlights.
70-
init(textView: STTextView,
71-
highlightProvider: HighlightProviding?,
72-
theme: EditorTheme,
73-
attributeProvider: ThemeAttributesProviding,
74-
language: CodeLanguage) {
70+
init(
71+
textView: STTextView,
72+
highlightProvider: HighlightProviding?,
73+
theme: EditorTheme,
74+
attributeProvider: ThemeAttributesProviding,
75+
language: CodeLanguage
76+
) {
7577
self.textView = textView
7678
self.highlightProvider = highlightProvider
7779
self.theme = theme
@@ -80,15 +82,13 @@ class Highlighter: NSObject {
8082

8183
super.init()
8284

83-
highlightProvider?.setLanguage(codeLanguage: language)
84-
8585
guard textView.textContentStorage?.textStorage != nil else {
8686
assertionFailure("Text view does not have a textStorage")
8787
return
8888
}
8989

9090
textView.textContentStorage?.textStorage?.delegate = self
91-
highlightProvider?.setUp(textView: textView)
91+
highlightProvider?.setUp(textView: textView, codeLanguage: language)
9292

9393
if let scrollView = textView.enclosingScrollView {
9494
NotificationCenter.default.addObserver(self,
@@ -113,16 +113,15 @@ class Highlighter: NSObject {
113113
/// Sets the language and causes a re-highlight of the entire text.
114114
/// - Parameter language: The language to update to.
115115
public func setLanguage(language: CodeLanguage) {
116-
highlightProvider?.setLanguage(codeLanguage: language)
116+
highlightProvider?.setUp(textView: textView, codeLanguage: language)
117117
invalidate()
118118
}
119119

120120
/// Sets the highlight provider. Will cause a re-highlight of the entire text.
121121
/// - Parameter provider: The provider to use for future syntax highlights.
122122
public func setHighlightProvider(_ provider: HighlightProviding) {
123123
self.highlightProvider = provider
124-
highlightProvider?.setLanguage(codeLanguage: language)
125-
highlightProvider?.setUp(textView: textView)
124+
highlightProvider?.setUp(textView: textView, codeLanguage: language)
126125
invalidate()
127126
}
128127

@@ -172,14 +171,11 @@ private extension Highlighter {
172171
guard let attributeProvider = self?.attributeProvider,
173172
let textView = self?.textView else { return }
174173

175-
// Mark these indices as not pending and valid
176174
self?.pendingSet.remove(integersIn: rangeToHighlight)
177-
self?.validSet.formUnion(IndexSet(integersIn: rangeToHighlight))
178-
179-
// If this range does not exist in the visible set, we can exit.
180-
if !(self?.visibleSet ?? .init()).contains(integersIn: rangeToHighlight) {
175+
guard self?.visibleSet.intersects(integersIn: rangeToHighlight) ?? false else {
181176
return
182177
}
178+
self?.validSet.formUnion(IndexSet(integersIn: rangeToHighlight))
183179

184180
// Try to create a text range for invalidating. If this fails we fail silently
185181
guard let textContentManager = textView.textLayoutManager.textContentManager,
@@ -251,7 +247,9 @@ private extension Highlighter {
251247
private extension Highlighter {
252248
/// Updates the view to highlight newly visible text when the textview is scrolled or bounds change.
253249
@objc func visibleTextChanged(_ notification: Notification) {
254-
visibleSet = IndexSet(integersIn: textView.visibleTextRange ?? NSRange())
250+
if let newVisibleRange = textView.visibleTextRange {
251+
visibleSet = IndexSet(integersIn: newVisibleRange)
252+
}
255253

256254
// Any indices that are both *not* valid and in the visible text range should be invalidated
257255
let newlyInvalidSet = visibleSet.subtracting(validSet)

0 commit comments

Comments
 (0)