diff --git a/Package.resolved b/Package.resolved index 5234ed9..70d0048 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "OperationPlus", - "repositoryURL": "https://github.com/ChimeHQ/OperationPlus", - "state": { - "branch": null, - "revision": "f5d2899e113943d7ea8f73462639217acc6b92b9", - "version": "1.5.4" - } - }, { "package": "Rearrange", "repositoryURL": "https://github.com/ChimeHQ/Rearrange", @@ -24,8 +15,8 @@ "repositoryURL": "https://github.com/ChimeHQ/SwiftTreeSitter", "state": { "branch": null, - "revision": "8f7413d55c180b4b1184a4ec5cee652d1c9e267a", - "version": "0.4.1" + "revision": "fc0a8623f40ac9f0c5ed6bc7bbcb03b94ebb18d1", + "version": "0.5.0" } }, { @@ -33,8 +24,8 @@ "repositoryURL": "https://github.com/krzyzanowskim/tree-sitter-xcframework", "state": { "branch": null, - "revision": "e24b77438e2d40f796875101e45a5b5f93aaac25", - "version": "0.206.7" + "revision": "668fc0bae9ff3152b458d13007ec9af7806d0655", + "version": "0.206.9" } } ] diff --git a/Package.swift b/Package.swift index f4128a8..f9184bb 100644 --- a/Package.swift +++ b/Package.swift @@ -9,12 +9,11 @@ let package = Package( .library(name: "Neon", targets: ["Neon"]), ], dependencies: [ - .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", from: "0.4.1"), + .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", from: "0.5.0"), .package(url: "https://github.com/ChimeHQ/Rearrange", from: "1.5.1"), - .package(url: "https://github.com/ChimeHQ/OperationPlus", from: "1.5.4"), ], targets: [ - .target(name: "Neon", dependencies: ["SwiftTreeSitter", "Rearrange", "OperationPlus"]), + .target(name: "Neon", dependencies: ["SwiftTreeSitter", "Rearrange"]), .testTarget(name: "NeonTests", dependencies: ["Neon"]), ] ) diff --git a/README.md b/README.md index e42aa3c..7c80e8c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ Some things to consider: ## TreeSitterClient -This class is an asynchronous interface to tree-sitter. It provides an UTF-16 code-point (`NSString`-compatible) API for edits, invalidations, and queries. It can process edits of `String` objects, or raw bytes for even greater flexibility and performance. Invalidations are translated to the current content state, even if a queue of edits are still being processed. +This class is an asynchronous interface to tree-sitter. It provides an UTF-16 code-point (`NSString`-compatible) API for edits, invalidations, and queries. It can process edits of `String` objects, or raw bytes. Invalidations are translated to the current content state, even if a queue of edits are still being processed. + +It goes through great lengths to provide APIs that can be both synchronous, asynchronous, or both depending on the state of the system. This kind of interface can be critical for prividing a flicker-free highlighting and live typing interactions. TreeSitterClient requires a function that can translate UTF16 code points (ie `NSRange`.location) to a tree-sitter `Point` (line + offset). @@ -84,14 +86,17 @@ client.willChangeContent(in: range) client.didChangeContent(to: string, in: range, delta: delta, limit: limit) // step 4: run queries -// you can execute these queries in the invalidationHandler +// you can execute these queries directly in the invalidationHandler, if desired + +// Many tree-sitter highlight queries contain predicates. These are both expensive +// and complex to resolve. This is an optional feature - you can just skip it. Doing +// so makes the process both faster and simpler, but could result in lower-quality +// and even incorrect highlighting. -// produce a function that can read your text content -let provider = { contentRange -> Result in ... } +let provider: TreeSitterClient.TextProvider = { (range, _) -> String? in ... } -client.executeHighlightQuery(query, in: range, contentProvider: provider) { result in - // TreeSitterClient.HighlightMatch objects will tell you about the - // highlights.scm name and range in your text +client.executeHighlightsQuery(query, in: range, textProvider: provider) { result in + // Token values will tell you the highlights.scm name and range in your text } ``` diff --git a/Sources/Neon/Token.swift b/Sources/Neon/Token.swift new file mode 100644 index 0000000..6f70287 --- /dev/null +++ b/Sources/Neon/Token.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct Token { + public let name: String + public let range: NSRange + + public init(name: String, range: NSRange) { + self.name = name + self.range = range + } +} + +extension Token: Hashable { +} diff --git a/Sources/Neon/TreeSitter+Extensions.swift b/Sources/Neon/TreeSitter+Extensions.swift new file mode 100644 index 0000000..0cd962b --- /dev/null +++ b/Sources/Neon/TreeSitter+Extensions.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftTreeSitter + +extension Point { + public typealias LocationTransformer = (Int) -> Point? +} + +extension InputEdit { + init?(range: NSRange, delta: Int, oldEndPoint: Point, transformer: Point.LocationTransformer) { + let startLocation = range.location + let newEndLocation = range.max + delta + + if newEndLocation < 0 { + assertionFailure("invalid range/delta") + return nil + } + + guard + let startPoint = transformer(startLocation), + let newEndPoint = transformer(newEndLocation) + else { + return nil + } + + self.init(startByte: UInt32(range.location * 2), + oldEndByte: UInt32(range.max * 2), + newEndByte: UInt32(newEndLocation * 2), + startPoint: startPoint, + oldEndPoint: oldEndPoint, + newEndPoint: newEndPoint) + } +} diff --git a/Sources/Neon/TreeSitterClient.swift b/Sources/Neon/TreeSitterClient.swift index f74cc28..f2f87b1 100644 --- a/Sources/Neon/TreeSitterClient.swift +++ b/Sources/Neon/TreeSitterClient.swift @@ -1,13 +1,10 @@ import Foundation import SwiftTreeSitter import Rearrange -import OperationPlus public enum TreeSitterClientError: Error { - case parseStateNotUpToDate - case parseStateInvalid - case unableToTransformRange(NSRange) - case unableToTransformByteRange(Range) + case staleState + case stateInvalid case staleContent } @@ -31,21 +28,21 @@ public final class TreeSitterClient { } } - private struct Thresholds { - let maxDelta = 1024 - let maxTotal = 150000 - } - private var oldEndPoint: Point? private let parser: Parser private var parseState: TreeSitterParseState private var outstandingEdits: [ContentEdit] private var version: Int - private let parseQueue: OperationQueue - private var maximumProcessedLocation: Int - private let thresholds: Thresholds + private let queue: DispatchQueue + private let semaphore: DispatchSemaphore + private let synchronousLengthThreshold: Int + + // This was roughly determined to be the limit in characters + // before it's likely that tree-sitter edit processing + // and tree-diffing will start to become noticibly laggy + private let synchronousContentLengthThreshold: Int = 1_000_000 - public let transformer: TreeSitterCoordinateTransformer + public let locationTransformer: Point.LocationTransformer public var computeInvalidations: Bool /// Invoked when parts of the text content have changed @@ -58,39 +55,25 @@ public final class TreeSitterClient { /// was true at the time an edit was applied. public var invalidationHandler: (IndexSet) -> Void - init(language: Language, transformer: TreeSitterCoordinateTransformer, synchronousLengthThreshold: Int? = 1024) throws { + public init(language: Language, transformer: @escaping Point.LocationTransformer, synchronousLengthThreshold: Int = 1024) throws { self.parser = Parser() self.parseState = TreeSitterParseState(tree: nil) self.outstandingEdits = [] self.computeInvalidations = true self.version = 0 - self.maximumProcessedLocation = 0 - self.parseQueue = OperationQueue.serialQueue(named: "com.chimehq.Neon.TreeSitterClient") + self.queue = DispatchQueue(label: "com.chimehq.Neon.TreeSitterClient") + self.semaphore = DispatchSemaphore(value: 1) try parser.setLanguage(language) self.invalidationHandler = { _ in } - self.transformer = transformer - self.thresholds = Thresholds() + self.locationTransformer = transformer + self.synchronousLengthThreshold = synchronousLengthThreshold } - public convenience init(language: Language, locationToPoint: @escaping (Int) -> Point?) throws { - let transformer = TreeSitterCoordinateTransformer(locationToPoint: locationToPoint) - - try self.init(language: language, transformer: transformer) - } - - private var hasQueuedEdits: Bool { + private var hasQueuedWork: Bool { return outstandingEdits.count > 0 } - - private var mustRunAsync: Bool { - return hasQueuedEdits || maximumProcessedLocation > thresholds.maxTotal - } - -// public func exceedsSynchronousThreshold(_ value: Int) -> Bool { -// return synchronousLengthThreshold.map { value >= $0 } ?? false -// } } extension TreeSitterClient { @@ -101,7 +84,7 @@ extension TreeSitterClient { /// /// - Parameter range: the range of content that will be affected by an edit public func willChangeContent(in range: NSRange) { - oldEndPoint = transformer.locationToPoint(range.max) + oldEndPoint = locationTransformer(range.max) } /// Process a change in the underlying text content. @@ -129,7 +112,7 @@ extension TreeSitterClient { self.oldEndPoint = nil - guard let inputEdit = transformer.inputEdit(for: range, delta: delta, oldEndPoint: oldEndPoint) else { + guard let inputEdit = InputEdit(range: range, delta: delta, oldEndPoint: oldEndPoint, transformer: locationTransformer) else { assertionFailure("unable to build InputEdit") return } @@ -167,33 +150,40 @@ extension TreeSitterClient { extension TreeSitterClient { func processEdit(_ edit: ContentEdit, readHandler: @escaping Parser.ReadBlock, completionHandler: @escaping () -> Void) { - let largeEdit = edit.size > thresholds.maxDelta - let shouldEnqueue = mustRunAsync || largeEdit - let doInvalidations = computeInvalidations + dispatchPrecondition(condition: .onQueue(.main)) - self.version += 1 + let largeEdit = edit.size > synchronousLengthThreshold + let largeDocument = edit.limit > synchronousContentLengthThreshold + let runAsync = hasQueuedWork || largeEdit || largeDocument + let doInvalidations = computeInvalidations - guard shouldEnqueue else { + if runAsync == false { processEditSync(edit, withInvalidations: doInvalidations, readHandler: readHandler, completionHandler: completionHandler) return } - self.processEditAsync(edit, withInvalidations: doInvalidations, readHandler: readHandler, completionHandler: completionHandler) + processEditAsync(edit, withInvalidations: doInvalidations, readHandler: readHandler, completionHandler: completionHandler) } - private func updateState(_ newState: TreeSitterParseState, limit: Int) { - self.parseState = newState - self.maximumProcessedLocation = limit - } - - private func processEditSync(_ edit: ContentEdit, withInvalidations doInvalidations: Bool, readHandler: @escaping Parser.ReadBlock, completionHandler: () -> Void) { + private func applyEdit(_ edit: ContentEdit, readHandler: @escaping Parser.ReadBlock) -> (TreeSitterParseState, TreeSitterParseState) { + self.semaphore.wait() let state = self.parseState state.applyEdit(edit.inputEdit) - let newState = self.parser.parse(state: state, readHandler: readHandler) - let set = doInvalidations ? self.computeInvalidatedSet(from: state, to: newState, with: edit) : IndexSet() + self.parseState = self.parser.parse(state: state, readHandler: readHandler) - updateState(newState, limit: edit.limit) + let oldState = state.copy() + let newState = parseState.copy() + + self.semaphore.signal() + + return (oldState, newState) + } + + private func processEditSync(_ edit: ContentEdit, withInvalidations doInvalidations: Bool, readHandler: @escaping Parser.ReadBlock, completionHandler: () -> Void) { + let (oldState, newState) = applyEdit(edit, readHandler: readHandler) + + let set = doInvalidations ? self.computeInvalidatedSet(from: oldState, to: newState, with: edit) : IndexSet() completionHandler() @@ -203,31 +193,28 @@ extension TreeSitterClient { private func processEditAsync(_ edit: ContentEdit, withInvalidations doInvalidations: Bool, readHandler: @escaping Parser.ReadBlock, completionHandler: @escaping () -> Void) { outstandingEdits.append(edit) - let state = self.parseState.copy() - - parseQueue.addAsyncOperation { opCompletion in - state.applyEdit(edit.inputEdit) - let newState = self.parser.parse(state: state, readHandler: readHandler) - let set = doInvalidations ? self.computeInvalidatedSet(from: state, to: newState, with: edit) : IndexSet() + queue.async { + let (oldState, newState) = self.applyEdit(edit, readHandler: readHandler) - OperationQueue.main.addOperation { - self.updateState(newState, limit: edit.limit) + DispatchQueue.global().async { + // we can safely compute the invalidations on another queue + let set = doInvalidations ? self.computeInvalidatedSet(from: oldState, to: newState, with: edit) : IndexSet() - let completedEdit = self.outstandingEdits.removeFirst() + OperationQueue.main.addOperation { + let completedEdit = self.outstandingEdits.removeFirst() - assert(completedEdit.inputEdit == edit.inputEdit) + assert(completedEdit.inputEdit == edit.inputEdit) - self.dispatchInvalidatedSet(set) + self.dispatchInvalidatedSet(set) - opCompletion() - completionHandler() + completionHandler() + } } } } func computeInvalidatedSet(from oldState: TreeSitterParseState, to newState: TreeSitterParseState, with edit: ContentEdit) -> IndexSet { - let changedByteRanges = oldState.changedRanges(for: newState) - let changedRanges = changedByteRanges.compactMap({ transformer.computeRange(from: $0) }) + let changedRanges = oldState.changedByteRanges(for: newState).map({ $0.range }) // we have to ensure that any invalidated ranges don't fall outside of limit let clampedRanges = changedRanges.compactMap({ $0.clamped(to: edit.limit) }) @@ -267,112 +254,171 @@ extension TreeSitterClient { } extension TreeSitterClient { - public typealias ContentProvider = (NSRange) -> Result public typealias QueryCursorResult = Result + public typealias ResolvingQueryCursorResult = Result - public func executeQuery(_ query: Query, in range: NSRange, contentProvider: @escaping ContentProvider, completionHandler: @escaping (QueryCursorResult) -> Void) { - let largeRange = range.length > thresholds.maxDelta - let pending = range.max > maximumProcessedLocation + /// Determine if it is likely that a synchronous query will execute quickly + public func canAttemptSynchronousQuery(in range: NSRange) -> Bool { + let largeRange = range.length > synchronousLengthThreshold - let shouldEnqueue = mustRunAsync || largeRange || pending + return hasQueuedWork == false && largeRange == false + } - if shouldEnqueue == false { - completionHandler(executeQuerySynchronously(query, in: range, contentProvider: contentProvider)) + /// Executes a query and returns a ResolvingQueryCursor + /// + /// This method runs a query on the current state of the content. It guarantees + /// that a successful result corresponds to that state. It must be invoked from + /// the main thread and will always call `completionHandler` on the main thread as well. + /// + /// Note that if you set `preferSynchronous` to true, `prefetchMatches` is ignored, + /// since it will only incur additional overhead. + /// + /// - Parameter query: the query to execute + /// - Parameter range: constrain the query to this range + /// - Parameter preferSynchronous: attempt to run the query synchronously if possible + /// - Parameter prefetchMatches: prefetch matches in the background + /// - Parameter completionHandler: returns the result + public func executeResolvingQuery(_ query: Query, + in range: NSRange, + preferSynchronous: Bool = false, + prefetchMatches: Bool = true, + completionHandler: @escaping (ResolvingQueryCursorResult) -> Void) { + dispatchPrecondition(condition: .onQueue(.main)) + + if preferSynchronous && canAttemptSynchronousQuery(in: range) { + let result = executeResolvingQuerySynchronously(query, in: range) + + completionHandler(result) return } + // We only want to produce results that match the *current* state + // of the content... let startedVersion = version - let op = AsyncBlockProducerOperation { opCompletion in - OperationQueue.main.addOperation { - guard startedVersion == self.version else { - opCompletion(.failure(.staleContent)) + queue.async { + // .. so at the state could be mutated at at any point. But, + // let's be optimistic and only check once at the end. + + self.semaphore.wait() + let state = self.parseState.copy() + self.semaphore.signal() - return + DispatchQueue.global().async { + let result = self.executeResolvingQuerySynchronouslyWithoutCheck(query, + in: range, + with: state) + + if case .success(let cursor) = result, prefetchMatches { + cursor.prefetchMatches() } - assert(range.max <= self.maximumProcessedLocation) + OperationQueue.main.addOperation { + guard startedVersion == self.version else { + completionHandler(.failure(.staleContent)) - let result = self.executeQuerySynchronouslyWithoutCheck(query, in: range, contentProvider: contentProvider) + return + } - opCompletion(result) + completionHandler(result) + } } } + } - op.resultCompletionBlock = completionHandler - - parseQueue.addOperation(op) + /// Executes a query and returns a ResolvingQueryCursor + /// + /// This is the async version of executeResolvingQuery(:in:preferSynchronous:prefetchMatches:completionHandler:) + @available(macOS 10.15, iOS 13.0, *) + public func resolvingQueryCursor(with query: Query, in range: NSRange, preferSynchronous: Bool = false, prefetchMatches: Bool = true) async throws -> ResolvingQueryCursor { + try await withCheckedThrowingContinuation { continuation in + self.executeResolvingQuery(query, in: range, preferSynchronous: preferSynchronous) { result in + continuation.resume(with: result) + } + } } +} - public func executeQuerySynchronously(_ query: Query, in range: NSRange, contentProvider: @escaping ContentProvider) -> QueryCursorResult { - let shouldEnqueue = hasQueuedEdits || range.max > maximumProcessedLocation +extension TreeSitterClient { + public func executeResolvingQuerySynchronously(_ query: Query, in range: NSRange) -> ResolvingQueryCursorResult { + dispatchPrecondition(condition: .onQueue(.main)) - if shouldEnqueue { - return .failure(.parseStateNotUpToDate) + if hasQueuedWork { + return .failure(.staleState) } - return executeQuerySynchronouslyWithoutCheck(query, in: range, contentProvider: contentProvider) + return executeResolvingQuerySynchronouslyWithoutCheck(query, in: range, with: parseState) } - private func executeQuerySynchronouslyWithoutCheck(_ query: Query, in range: NSRange, contentProvider: @escaping ContentProvider) -> QueryCursorResult { - guard let node = parseState.tree?.rootNode else { - return .failure(.parseStateInvalid) - } + private func executeResolvingQuerySynchronouslyWithoutCheck(_ query: Query, in range: NSRange, with state: TreeSitterParseState) -> ResolvingQueryCursorResult { + return executeQuerySynchronouslyWithoutCheck(query, in: range, with: state) + .map({ ResolvingQueryCursor(cursor: $0) }) + } +} - let textProvider: PredicateTextProvider = { (byteRange, _) -> Result in - guard let range = self.transformer.computeRange(from: byteRange) else { - return .failure(TreeSitterClientError.unableToTransformByteRange(byteRange)) - } +extension TreeSitterClient { + public func executeQuerySynchronously(_ query: Query, in range: NSRange) -> QueryCursorResult { + dispatchPrecondition(condition: .onQueue(.main)) - return contentProvider(range) + if hasQueuedWork { + return .failure(.staleState) } - guard let byteRange = transformer.computeByteRange(from: range) else { - return .failure(.unableToTransformRange(range)) + return executeQuerySynchronouslyWithoutCheck(query, in: range, with: parseState) + } + + private func executeQuerySynchronouslyWithoutCheck(_ query: Query, in range: NSRange, with state: TreeSitterParseState) -> QueryCursorResult { + guard let node = state.tree?.rootNode else { + return .failure(.stateInvalid) } - let cursor = query.execute(node: node, textProvider: textProvider) + // critical to keep a reference to the tree, so it survives as long as the query + let cursor = query.execute(node: node, in: state.tree) - cursor.setByteRange(range: byteRange) + cursor.setRange(range) return .success(cursor) } } extension TreeSitterClient { - public struct HighlightMatch { - public var name: String - public var range: NSRange - } - - private func findHighlightMatches(with cursor: QueryCursor) -> [HighlightMatch] { - var pairs = [HighlightMatch]() + public typealias TextProvider = ResolvingQueryCursor.TextProvider - while let match = try? cursor.nextMatch() { - for capture in match.captures { - guard let name = capture.name else { continue } - let byteRange = capture.node.byteRange + private func tokensFromCursor(_ cursor: ResolvingQueryCursor, textProvider: TextProvider?) -> [Token] { + if let textProvider = textProvider { + cursor.prepare(with: textProvider) + } - guard let range = transformer.computeRange(from: byteRange) else { - continue - } + return cursor + .map({ $0.captures }) + .flatMap({ $0 }) + .compactMap { capture -> Token? in + guard let name = capture.name else { return nil } - pairs.append(HighlightMatch(name: name, range: range)) + return Token(name: name, range: capture.node.range) } - } - - return pairs } - public func executeHighlightQuery(_ query: Query, in range: NSRange, contentProvider: @escaping ContentProvider, completionHandler: @escaping (Result<[HighlightMatch], TreeSitterClientError>) -> Void) { - executeQuery(query, in: range, contentProvider: contentProvider) { result in - switch result { - case .failure(let error): - completionHandler(.failure(error)) - case .success(let cursor): - let highlights = self.findHighlightMatches(with: cursor) + public func executeHighlightsQuery(_ query: Query, + in range: NSRange, + preferSynchronous: Bool = false, + textProvider: TextProvider? = nil, + completionHandler: @escaping (Result<[Token], TreeSitterClientError>) -> Void) { + executeResolvingQuery(query, in: range, preferSynchronous: preferSynchronous) { cursorResult in + let result = cursorResult.map({ self.tokensFromCursor($0, textProvider: textProvider) }) + + completionHandler(result) + } + } - completionHandler(.success(highlights)) + @available(macOS 10.15, iOS 13.0, *) + public func highlights(with query: Query, + in range: NSRange, + preferSynchronous: Bool = false, + textProvider: TextProvider? = nil) async throws -> [Token] { + try await withCheckedThrowingContinuation { continuation in + self.executeHighlightsQuery(query, in: range, preferSynchronous: preferSynchronous, textProvider: textProvider) { result in + continuation.resume(with: result) } } } diff --git a/Sources/Neon/TreeSitterCoordinateTransformer.swift b/Sources/Neon/TreeSitterCoordinateTransformer.swift deleted file mode 100644 index 393c7e6..0000000 --- a/Sources/Neon/TreeSitterCoordinateTransformer.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import Rearrange -import SwiftTreeSitter - -public struct TreeSitterCoordinateTransformer { - public var locationToPoint: (Int) -> Point? - public var locationToByteOffset: (Int) -> UInt32? - public var byteOffsetToLocation: (UInt32) -> Int? - - public init(locationToPoint: @escaping (Int) -> Point?) { - self.locationToPoint = locationToPoint - self.locationToByteOffset = { UInt32($0 * 2) } - self.byteOffsetToLocation = { Int($0 / 2) } - } -} - -public extension TreeSitterCoordinateTransformer { - func computeRange(from byteRange: Range) -> NSRange? { - guard - let start = byteOffsetToLocation(byteRange.lowerBound), - let end = byteOffsetToLocation(byteRange.upperBound) - else { - return nil - } - - return NSRange(start.. Range? { - guard - let start = locationToByteOffset(range.lowerBound), - let end = locationToByteOffset(range.upperBound) - else { - return nil - } - - return start.. InputEdit? { - let startLocation = range.location - let newEndLocation = range.max + delta - - if newEndLocation < 0 { - assertionFailure("invalid range/delta") - return nil - } - - guard - let startByte = locationToByteOffset(range.location), - let oldEndByte = locationToByteOffset(range.max), - let newEndByte = locationToByteOffset(newEndLocation), - let startPoint = locationToPoint(startLocation), - let newEndPoint = locationToPoint(newEndLocation) - else { - return nil - } - - return InputEdit(startByte: startByte, - oldEndByte: oldEndByte, - newEndByte: newEndByte, - startPoint: startPoint, - oldEndPoint: oldEndPoint, - newEndPoint: newEndPoint) - } -} diff --git a/Sources/Neon/TreeSitterParseState.swift b/Sources/Neon/TreeSitterParseState.swift index 695bdf0..334dadb 100644 --- a/Sources/Neon/TreeSitterParseState.swift +++ b/Sources/Neon/TreeSitterParseState.swift @@ -20,7 +20,7 @@ struct TreeSitterParseState { tree?.edit(edit) } - func changedRanges(for otherState: TreeSitterParseState) -> [Range] { + func changedByteRanges(for otherState: TreeSitterParseState) -> [Range] { let otherTree = otherState.tree switch (tree, otherTree) {