Skip to content

Commit

Permalink
TreeSitter Performance And Stability (#263)
Browse files Browse the repository at this point in the history
### 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
  • Loading branch information
thecoolwinter authored Sep 8, 2024
1 parent fbabc59 commit 7d08e74
Show file tree
Hide file tree
Showing 21 changed files with 973 additions and 386 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/CodeEditTextView.git",
"state" : {
"revision" : "eb1d38247a45bc678b5a23a65d6f6df6c56519e4",
"version" : "0.7.5"
"revision" : "2619cb945b4d6c2fc13f22ba873ba891f552b0f3",
"version" : "0.7.6"
}
},
{
Expand All @@ -41,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "ee97538f5b81ae89698fd95938896dec5217b148",
"version" : "1.1.1"
"revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
"version" : "1.1.3"
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct ContentView: View {
@AppStorage("wrapLines") private var wrapLines: Bool = true
@State private var cursorPositions: [CursorPosition] = []
@AppStorage("systemCursor") private var useSystemCursor: Bool = false
@State private var isInLongParse = false

init(document: Binding<CodeEditSourceEditorExampleDocument>, fileURL: URL?) {
self._document = document
Expand Down Expand Up @@ -47,21 +48,47 @@ struct ContentView: View {
.zIndex(2)
.background(Color(NSColor.windowBackgroundColor))
Divider()
CodeEditSourceEditor(
$document.text,
language: language,
theme: theme,
font: font,
tabWidth: 4,
lineHeight: 1.2,
wrapLines: wrapLines,
cursorPositions: $cursorPositions,
useSystemCursor: useSystemCursor
)
ZStack {
if isInLongParse {
VStack {
HStack {
Spacer()
Text("Parsing document...")
Spacer()
}
.padding(4)
.background(Color(NSColor.windowBackgroundColor))
Spacer()
}
.zIndex(2)
.transition(.opacity)
}
CodeEditSourceEditor(
$document.text,
language: language,
theme: theme,
font: font,
tabWidth: 4,
lineHeight: 1.2,
wrapLines: wrapLines,
cursorPositions: $cursorPositions,
useSystemCursor: useSystemCursor
)
}
}
.onAppear {
self.language = detectLanguage(fileURL: fileURL) ?? .default
}
.onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in
withAnimation(.easeIn(duration: 0.1)) {
isInLongParse = true
}
}
.onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in
withAnimation(.easeIn(duration: 0.1)) {
isInLongParse = false
}
}
}

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

// When there's a single cursor, display the line and column.
return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)"
return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column) Range: \(cursorPositions[0].range)"
}
}

Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ let package = Package(
dependencies: [
"CodeEditTextView",
"CodeEditLanguages",
"TextFormation",
"TextFormation"
],
plugins: [
.plugin(name: "SwiftLint", package: "SwiftLintPlugin")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// DispatchQueue+dispatchMainIfNot.swift
// CodeEditSourceEditor
//
// Created by Khan Winter on 9/2/24.
//

import Foundation

/// Helper methods for dispatching (sync or async) on the main queue only if the calling thread is not already the
/// main queue.

extension DispatchQueue {
/// Executes the work item on the main thread, dispatching asynchronously if the thread is not the main thread.
/// - Parameter item: The work item to execute on the main thread.
static func dispatchMainIfNot(_ item: @escaping () -> Void) {
if Thread.isMainThread {
item()
} else {
DispatchQueue.main.async {
item()
}
}
}

/// Executes the work item on the main thread, keeping control on the calling thread until the work item is
/// executed if not already on the main thread.
/// - Parameter item: The work item to execute.
/// - Returns: The value of the work item.
static func syncMainIfNot<T>(_ item: @escaping () -> T) -> T {
if Thread.isMainThread {
return item()
} else {
return DispatchQueue.main.sync {
return item()
}
}
}
}
27 changes: 27 additions & 0 deletions Sources/CodeEditSourceEditor/Extensions/Result+ThrowOrReturn.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// Result+ThrowOrReturn.swift
// CodeEditSourceEditor
//
// Created by Khan Winter on 9/2/24.
//

import Foundation

extension Result {
func throwOrReturn() throws -> Success {
switch self {
case let .success(success):
return success
case let .failure(failure):
throw failure
}
}

var isSuccess: Bool {
if case .success = self {
return true
} else {
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,42 @@ import CodeEditTextView
import SwiftTreeSitter

extension TextView {
/// Creates a block for safely reading data into a parser's read block.
///
/// If the thread is the main queue, executes synchronously.
/// Otherwise it will block the calling thread and execute the block on the main queue, returning control to the
/// calling queue when the block is finished running.
///
/// - Returns: A new block for reading contents for tree-sitter.
func createReadBlock() -> Parser.ReadBlock {
return { [weak self] byteOffset, _ in
let limit = self?.documentRange.length ?? 0
let location = byteOffset / 2
let end = min(location + (1024), limit)
if location > end || self == nil {
// Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations.
return nil
let workItem: () -> Data? = {
let limit = self?.documentRange.length ?? 0
let location = byteOffset / 2
let end = min(location + (TreeSitterClient.Constants.charsToReadInBlock), limit)
if location > end || self == nil {
// Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations.
return nil
}
let range = NSRange(location..<end)
return self?.stringForRange(range)?.data(using: String.nativeUTF16Encoding)
}
let range = NSRange(location..<end)
return self?.stringForRange(range)?.data(using: String.nativeUTF16Encoding)
return DispatchQueue.syncMainIfNot(workItem)
}
}

/// Creates a block for safely reading data for a text provider.
///
/// If the thread is the main queue, executes synchronously.
/// Otherwise it will block the calling thread and execute the block on the main queue, returning control to the
/// calling queue when the block is finished running.
///
/// - Returns: A new block for reading contents for tree-sitter.
func createReadCallback() -> SwiftTreeSitter.Predicate.TextProvider {
return { [weak self] range, _ in
return self?.stringForRange(range)
let workItem: () -> String? = {
self?.stringForRange(range)
}
return DispatchQueue.syncMainIfNot(workItem)
}
}
}
24 changes: 21 additions & 3 deletions Sources/CodeEditSourceEditor/Highlighting/HighlightProviding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,25 @@ import CodeEditTextView
import CodeEditLanguages
import AppKit

/// A single-case error that should be thrown when an operation should be retried.
public enum HighlightProvidingError: Error {
case operationCancelled
}

/// The protocol a class must conform to to be used for highlighting.
public protocol HighlightProviding: AnyObject {
/// Called once to set up the highlight provider with a data source and language.
/// - Parameters:
/// - textView: The text view to use as a text source.
/// - codeLanguage: The language that should be used by the highlighter.
@MainActor
func setUp(textView: TextView, codeLanguage: CodeLanguage)

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

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

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

extension HighlightProviding {
Expand Down
Loading

0 comments on commit 7d08e74

Please sign in to comment.