Skip to content

Commit 7d08e74

Browse files
TreeSitter Performance And Stability (#263)
### Description Stabilizes the highlighting and editing experience for large documents and slow tree-sitter languages. All async operations run using an updated execution manager that is safer, faster, and much easier to use. #### Safety Improvements: - Explicitly performs getting text for tree-sitter on the main thread. - Parses on the main thread, timing out every few ms to avoid clogging up the main thread. - To avoid potential corrupted tree-sitter state, the state object is now copied for each edit and applied to the client at the end of the edit. If the edit operation is canceled, the half-parsed state is thrown away. - `HighlightProviding` now has `@MainActor` marked callbacks and protocol required functions. In async contexts these will throw a compiler error if not called on the main thread. #### Performance Improvements: - If running asynchronously, tree-sitter edits cancel all previous edits. If an edit is canceled, the edit is added to an atomic queue. The next edit that isn't cancelled will pick up and apply all the queued edits. - This causes a massive performance improvement as tree-sitter's parser gets very stuck if the text doesn't match the tree-sitter tree. By keeping the text and edits in sync we reduce edit parse time drastically. - Instead of using a serial dispatch queue, the executor now uses Swift's shared thread pool via Tasks. On top of that, because we're controlling when tasks execute in a queue, operations that access the tree-sitter tree can now run in parallel. #### Highlighter Changes: - The `HighlightProviding` callbacks now return a `Result` object. If the result is a failure and returns a cancelled error, the highlighter now re-invalidates the queried ranges. This means when highlights are cancelled because of some other async operation, they are always eventually fulfilled. - The highlighter now logs errors from it's providers. #### TreeSitter Execution: - Operations make use of Swift `Task`s to execute, allowing us to use task cancellation, priority, etc. - Operations are now cancellable by priority, so reset operations can cancel all edits, highlights and resets, edits can cancel all edits and highlights, etc. - Cancelling an operation now has many more checks to ensure cancelled tasks don't perform extra work (while parsing, before starting an operation, while waiting in the queue). ### Related Issues * N/A ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots > Demo: Writing a simple C program with main, a string, and a few keywords in a large C file. > These use sqlite3.c for demos. It's just a large C file that I often use for performance demos. Current editing experience. Note incorrect highlights, extremely slow highlighting and maxed thread use. https://github.com/user-attachments/assets/348ba55f-4a27-4c53-8030-d1450c7c9327 New editing experience for large files, with metrics: https://github.com/user-attachments/assets/230e765a-345e-44ec-9054-b6da765032d9
1 parent fbabc59 commit 7d08e74

File tree

21 files changed

+973
-386
lines changed

21 files changed

+973
-386
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/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.

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct ContentView: View {
2020
@AppStorage("wrapLines") private var wrapLines: Bool = true
2121
@State private var cursorPositions: [CursorPosition] = []
2222
@AppStorage("systemCursor") private var useSystemCursor: Bool = false
23+
@State private var isInLongParse = false
2324

2425
init(document: Binding<CodeEditSourceEditorExampleDocument>, fileURL: URL?) {
2526
self._document = document
@@ -47,21 +48,47 @@ struct ContentView: View {
4748
.zIndex(2)
4849
.background(Color(NSColor.windowBackgroundColor))
4950
Divider()
50-
CodeEditSourceEditor(
51-
$document.text,
52-
language: language,
53-
theme: theme,
54-
font: font,
55-
tabWidth: 4,
56-
lineHeight: 1.2,
57-
wrapLines: wrapLines,
58-
cursorPositions: $cursorPositions,
59-
useSystemCursor: useSystemCursor
60-
)
51+
ZStack {
52+
if isInLongParse {
53+
VStack {
54+
HStack {
55+
Spacer()
56+
Text("Parsing document...")
57+
Spacer()
58+
}
59+
.padding(4)
60+
.background(Color(NSColor.windowBackgroundColor))
61+
Spacer()
62+
}
63+
.zIndex(2)
64+
.transition(.opacity)
65+
}
66+
CodeEditSourceEditor(
67+
$document.text,
68+
language: language,
69+
theme: theme,
70+
font: font,
71+
tabWidth: 4,
72+
lineHeight: 1.2,
73+
wrapLines: wrapLines,
74+
cursorPositions: $cursorPositions,
75+
useSystemCursor: useSystemCursor
76+
)
77+
}
6178
}
6279
.onAppear {
6380
self.language = detectLanguage(fileURL: fileURL) ?? .default
6481
}
82+
.onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in
83+
withAnimation(.easeIn(duration: 0.1)) {
84+
isInLongParse = true
85+
}
86+
}
87+
.onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in
88+
withAnimation(.easeIn(duration: 0.1)) {
89+
isInLongParse = false
90+
}
91+
}
6592
}
6693

6794
private func detectLanguage(fileURL: URL?) -> CodeLanguage? {
@@ -87,7 +114,7 @@ struct ContentView: View {
87114
}
88115

89116
// When there's a single cursor, display the line and column.
90-
return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)"
117+
return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column) Range: \(cursorPositions[0].range)"
91118
}
92119
}
93120

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ let package = Package(
4242
dependencies: [
4343
"CodeEditTextView",
4444
"CodeEditLanguages",
45-
"TextFormation",
45+
"TextFormation"
4646
],
4747
plugins: [
4848
.plugin(name: "SwiftLint", package: "SwiftLintPlugin")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// DispatchQueue+dispatchMainIfNot.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 9/2/24.
6+
//
7+
8+
import Foundation
9+
10+
/// Helper methods for dispatching (sync or async) on the main queue only if the calling thread is not already the
11+
/// main queue.
12+
13+
extension DispatchQueue {
14+
/// Executes the work item on the main thread, dispatching asynchronously if the thread is not the main thread.
15+
/// - Parameter item: The work item to execute on the main thread.
16+
static func dispatchMainIfNot(_ item: @escaping () -> Void) {
17+
if Thread.isMainThread {
18+
item()
19+
} else {
20+
DispatchQueue.main.async {
21+
item()
22+
}
23+
}
24+
}
25+
26+
/// Executes the work item on the main thread, keeping control on the calling thread until the work item is
27+
/// executed if not already on the main thread.
28+
/// - Parameter item: The work item to execute.
29+
/// - Returns: The value of the work item.
30+
static func syncMainIfNot<T>(_ item: @escaping () -> T) -> T {
31+
if Thread.isMainThread {
32+
return item()
33+
} else {
34+
return DispatchQueue.main.sync {
35+
return item()
36+
}
37+
}
38+
}
39+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Result+ThrowOrReturn.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 9/2/24.
6+
//
7+
8+
import Foundation
9+
10+
extension Result {
11+
func throwOrReturn() throws -> Success {
12+
switch self {
13+
case let .success(success):
14+
return success
15+
case let .failure(failure):
16+
throw failure
17+
}
18+
}
19+
20+
var isSuccess: Bool {
21+
if case .success = self {
22+
return true
23+
} else {
24+
return false
25+
}
26+
}
27+
}

Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,42 @@ import CodeEditTextView
1010
import SwiftTreeSitter
1111

1212
extension TextView {
13+
/// Creates a block for safely reading data into a parser's read block.
14+
///
15+
/// If the thread is the main queue, executes synchronously.
16+
/// Otherwise it will block the calling thread and execute the block on the main queue, returning control to the
17+
/// calling queue when the block is finished running.
18+
///
19+
/// - Returns: A new block for reading contents for tree-sitter.
1320
func createReadBlock() -> Parser.ReadBlock {
1421
return { [weak self] byteOffset, _ in
15-
let limit = self?.documentRange.length ?? 0
16-
let location = byteOffset / 2
17-
let end = min(location + (1024), limit)
18-
if location > end || self == nil {
19-
// Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations.
20-
return nil
22+
let workItem: () -> Data? = {
23+
let limit = self?.documentRange.length ?? 0
24+
let location = byteOffset / 2
25+
let end = min(location + (TreeSitterClient.Constants.charsToReadInBlock), limit)
26+
if location > end || self == nil {
27+
// Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations.
28+
return nil
29+
}
30+
let range = NSRange(location..<end)
31+
return self?.stringForRange(range)?.data(using: String.nativeUTF16Encoding)
2132
}
22-
let range = NSRange(location..<end)
23-
return self?.stringForRange(range)?.data(using: String.nativeUTF16Encoding)
33+
return DispatchQueue.syncMainIfNot(workItem)
2434
}
2535
}
26-
36+
/// Creates a block for safely reading data for a text provider.
37+
///
38+
/// If the thread is the main queue, executes synchronously.
39+
/// Otherwise it will block the calling thread and execute the block on the main queue, returning control to the
40+
/// calling queue when the block is finished running.
41+
///
42+
/// - Returns: A new block for reading contents for tree-sitter.
2743
func createReadCallback() -> SwiftTreeSitter.Predicate.TextProvider {
2844
return { [weak self] range, _ in
29-
return self?.stringForRange(range)
45+
let workItem: () -> String? = {
46+
self?.stringForRange(range)
47+
}
48+
return DispatchQueue.syncMainIfNot(workItem)
3049
}
3150
}
3251
}

Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,25 @@ import CodeEditTextView
1010
import CodeEditLanguages
1111
import AppKit
1212

13+
/// A single-case error that should be thrown when an operation should be retried.
14+
public enum HighlightProvidingError: Error {
15+
case operationCancelled
16+
}
17+
1318
/// The protocol a class must conform to to be used for highlighting.
1419
public protocol HighlightProviding: AnyObject {
1520
/// Called once to set up the highlight provider with a data source and language.
1621
/// - Parameters:
1722
/// - textView: The text view to use as a text source.
1823
/// - codeLanguage: The language that should be used by the highlighter.
24+
@MainActor
1925
func setUp(textView: TextView, codeLanguage: CodeLanguage)
2026

2127
/// Notifies the highlighter that an edit is going to happen in the given range.
2228
/// - Parameters:
2329
/// - textView: The text view to use.
2430
/// - range: The range of the incoming edit.
31+
@MainActor
2532
func willApplyEdit(textView: TextView, range: NSRange)
2633

2734
/// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted.
@@ -30,8 +37,14 @@ public protocol HighlightProviding: AnyObject {
3037
/// - textView: The text view to use.
3138
/// - range: The range of the edit.
3239
/// - delta: The length of the edit, can be negative for deletions.
33-
/// - Returns: an `IndexSet` containing all Indices to invalidate.
34-
func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping (IndexSet) -> Void)
40+
/// - Returns: An `IndexSet` containing all Indices to invalidate.
41+
@MainActor
42+
func applyEdit(
43+
textView: TextView,
44+
range: NSRange,
45+
delta: Int,
46+
completion: @escaping @MainActor (Result<IndexSet, Error>) -> Void
47+
)
3548

3649
/// Queries the highlight provider for any ranges to apply highlights to. The highlight provider should return an
3750
/// array containing all ranges to highlight, and the capture type for the range. Any ranges or indexes
@@ -40,7 +53,12 @@ public protocol HighlightProviding: AnyObject {
4053
/// - textView: The text view to use.
4154
/// - range: The range to query.
4255
/// - Returns: All highlight ranges for the queried ranges.
43-
func queryHighlightsFor(textView: TextView, range: NSRange, completion: @escaping ([HighlightRange]) -> Void)
56+
@MainActor
57+
func queryHighlightsFor(
58+
textView: TextView,
59+
range: NSRange,
60+
completion: @escaping @MainActor (Result<[HighlightRange], Error>) -> Void
61+
)
4462
}
4563

4664
extension HighlightProviding {

0 commit comments

Comments
 (0)