diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f7aa6f025..f9dc6dd26 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "86b980464bcb67693e2053283c7a99bdc6f358bc", - "version" : "0.7.3" + "revision" : "80911be6bcdae5e35ef5ed351adf6dda9b57e555", + "version" : "0.7.4" } }, { diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/xcshareddata/xcschemes/CodeEditSourceEditorExample.xcscheme b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/xcshareddata/xcschemes/CodeEditSourceEditorExample.xcscheme new file mode 100644 index 000000000..8a70ab4de --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/xcshareddata/xcschemes/CodeEditSourceEditorExample.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Documents/CodeEditSourceEditorExampleDocument.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Documents/CodeEditSourceEditorExampleDocument.swift index 42c53e699..8aa4b94ca 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Documents/CodeEditSourceEditorExampleDocument.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Documents/CodeEditSourceEditorExampleDocument.swift @@ -17,10 +17,7 @@ struct CodeEditSourceEditorExampleDocument: FileDocument { static var readableContentTypes: [UTType] { [ - .sourceCode, - .plainText, - .delimitedText, - .script + .item ] } diff --git a/Package.resolved b/Package.resolved index c8c0367c0..bcbfdf6e1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "86b980464bcb67693e2053283c7a99bdc6f358bc", - "version" : "0.7.3" + "revision" : "80911be6bcdae5e35ef5ed351adf6dda9b57e555", + "version" : "0.7.4" } }, { diff --git a/Package.swift b/Package.swift index 353d1865e..28d2855f2 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.7.3" + from: "0.7.4" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift b/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift index 03c13b4bb..46e4c2c87 100644 --- a/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift @@ -50,7 +50,7 @@ public struct CursorPosition: Sendable, Codable, Equatable { /// - range: The range of the position. /// - line: The line of the position. /// - column: The column of the position. - init(range: NSRange, line: Int, column: Int) { + package init(range: NSRange, line: Int, column: Int) { self.range = range self.line = line self.column = column diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 680d46172..4b0a347b7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -112,7 +112,7 @@ extension TextViewController { NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in guard self.view.window?.firstResponder == self.textView else { return event } - let charactersIgnoringModifiers = event.charactersIgnoringModifiers +// let charactersIgnoringModifiers = event.charactersIgnoringModifiers let commandKey = NSEvent.ModifierFlags.command.rawValue let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 74b922ff0..05820fae6 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -37,10 +37,10 @@ extension TextViewController { // Filters setUpOpenPairFilters(pairs: BracketPairs.allValues) + setUpTagFilter() setUpNewlineTabFilters(indentOption: indentOption) setUpDeletePairFilters(pairs: BracketPairs.allValues) setUpDeleteWhitespaceFilter(indentOption: indentOption) - setUpTagFilter() } /// Returns a `TextualIndenter` based on available language configuration. @@ -92,15 +92,13 @@ extension TextViewController { } private func setUpTagFilter() { - let filter = TagFilter(language: self.language.tsName) - textFilters.append(filter) - } - - func updateTagFilter() { - textFilters.removeAll { $0 is TagFilter } - - // Add new tagfilter with the updated language - textFilters.append(TagFilter(language: self.language.tsName)) + guard let treeSitterClient, language.id.shouldProcessTags() else { return } + textFilters.append(TagFilter( + language: self.language, + indentOption: indentOption, + lineEnding: textView.layoutManager.detectedLineEnding, + treeSitterClient: treeSitterClient + )) } /// Determines whether or not a text mutation should be applied. @@ -123,30 +121,14 @@ extension TextViewController { ) for filter in textFilters { - if let newlineFilter = filter as? NewlineProcessingFilter { - let action = mutation.applyWithTagProcessing( - in: textView, - using: newlineFilter, - with: whitespaceProvider, indentOption: indentOption - ) - switch action { - case .none: - continue - case .stop: - return true - case .discard: - return false - } - } else { - let action = filter.processMutation(mutation, in: textView, with: whitespaceProvider) - switch action { - case .none: - continue - case .stop: - return true - case .discard: - return false - } + let action = filter.processMutation(mutation, in: textView, with: whitespaceProvider) + switch action { + case .none: + continue + case .stop: + return true + case .discard: + return false } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 47c928321..736593406 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -39,7 +39,7 @@ public class TextViewController: NSViewController { public var language: CodeLanguage { didSet { highlighter?.setLanguage(language: language) - updateTagFilter() + setUpTextFormation() } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NewlineProcessingFilter+TagHandling.swift b/Sources/CodeEditSourceEditor/Extensions/NewlineProcessingFilter+TagHandling.swift deleted file mode 100644 index 4df863af5..000000000 --- a/Sources/CodeEditSourceEditor/Extensions/NewlineProcessingFilter+TagHandling.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// NewlineProcessingFilter+TagHandling.swift -// CodeEditSourceEditor -// -// Created by Roscoe Rubin-Rottenberg on 5/19/24. -// - -import Foundation -import TextStory -import TextFormation - -extension NewlineProcessingFilter { - - private func handleTags( - for mutation: TextMutation, - in interface: TextInterface, - with indentOption: IndentOption - ) -> Bool { - guard let precedingText = interface.substring( - from: NSRange( - location: 0, - length: mutation.range.location - ) - ) else { - return false - } - - guard let followingText = interface.substring( - from: NSRange( - location: mutation.range.location, - length: interface.length - mutation.range.location - ) - ) else { - return false - } - - let tagPattern = "<([a-zA-Z][a-zA-Z0-9]*)\\b[^>]*>" - - guard let precedingTagGroups = precedingText.groups(for: tagPattern), - let precedingTag = precedingTagGroups.first else { - return false - } - - guard followingText.range(of: "", options: .regularExpression) != nil else { - return false - } - - let insertionLocation = mutation.range.location - let newline = "\n" - let indentedNewline = newline + indentOption.stringValue - let newRange = NSRange(location: insertionLocation + indentedNewline.count, length: 0) - - // Insert indented newline first - interface.insertString(indentedNewline, at: insertionLocation) - // Then insert regular newline after indented newline - interface.insertString(newline, at: insertionLocation + indentedNewline.count) - interface.selectedRange = newRange - - return true - } - - public func processTags( - for mutation: TextMutation, - in interface: TextInterface, - with indentOption: IndentOption - ) -> FilterAction { - if handleTags(for: mutation, in: interface, with: indentOption) { - return .discard - } - return .none - } -} - -public extension TextMutation { - func applyWithTagProcessing( - in interface: TextInterface, - using filter: NewlineProcessingFilter, - with providers: WhitespaceProviders, - indentOption: IndentOption - ) -> FilterAction { - if filter.processTags(for: self, in: interface, with: indentOption) == .discard { - return .discard - } - - // Apply the original filter processing - return filter.processMutation(self, in: interface, with: providers) - } -} - -// Helper extension to extract capture groups -extension String { - func groups(for regexPattern: String) -> [String]? { - guard let regex = try? NSRegularExpression(pattern: regexPattern) else { return nil } - let nsString = self as NSString - let results = regex.matches(in: self, range: NSRange(location: 0, length: nsString.length)) - return results.first.map { result in - (1.. Bool) -> Node? { + for idx in 0..(_ callback: (Node) -> T) -> [T] { + var retVal: [T] = [] + for idx in 0.. Bool) -> [Node] { + var retVal: [Node] = [] + for idx in 0.. [String]? { + guard let regex = try? NSRegularExpression(pattern: regexPattern) else { return nil } + let nsString = self as NSString + let results = regex.matches(in: self, range: NSRange(location: 0, length: nsString.length)) + return results.first.map { result in + (1.. = [ + CodeLanguage.html.id.rawValue, + CodeLanguage.javascript.id.rawValue, + CodeLanguage.typescript.id.rawValue, + CodeLanguage.jsx.id.rawValue, + CodeLanguage.tsx.id.rawValue + ] + + func shouldProcessTags() -> Bool { + return Self.relevantLanguages.contains(self.rawValue) + } +} diff --git a/Sources/CodeEditSourceEditor/Filters/TagFilter.swift b/Sources/CodeEditSourceEditor/Filters/TagFilter.swift index e0e217466..3068a7794 100644 --- a/Sources/CodeEditSourceEditor/Filters/TagFilter.swift +++ b/Sources/CodeEditSourceEditor/Filters/TagFilter.swift @@ -1,6 +1,6 @@ // // TagFilter.swift -// +// CodeEditSourceEditor // // Created by Roscoe Rubin-Rottenberg on 5/18/24. // @@ -8,62 +8,293 @@ import Foundation import TextFormation import TextStory +import CodeEditTextView +import CodeEditLanguages +import SwiftTreeSitter struct TagFilter: Filter { - var language: String - private let newlineFilter = NewlineProcessingFilter() + enum Error: Swift.Error { + case invalidLanguage + case queryStringDataMissing + } + + // HTML tags that self-close and should be ignored + // https://developer.mozilla.org/en-US/docs/Glossary/Void_element + static let voidTags: Set = [ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" + ] + + var language: CodeLanguage + var indentOption: IndentOption + var lineEnding: LineEnding + var treeSitterClient: TreeSitterClient func processMutation( _ mutation: TextMutation, in interface: TextInterface, with whitespaceProvider: WhitespaceProviders ) -> FilterAction { - guard isRelevantLanguage() else { + guard mutation.delta > 0 && mutation.range.location > 0 else { return .none } + + let prevCharRange = NSRange(location: mutation.range.location - 1, length: 1) + guard interface.substring(from: prevCharRange) == ">" else { + return .none + } + + // Returns `nil` if it didn't find a valid start/end tag to complete. + guard let mutationLen = handleInsertionAfterTag(mutation, in: interface, with: whitespaceProvider) else { + return .none + } + + // Do some extra processing if it's a newline. + if mutation.string == lineEnding.rawValue { + return handleNewlineInsertion( + mutation, + in: interface, + with: whitespaceProvider, + tagMutationLen: mutationLen + ) + } else { return .none } - guard let range = Range(mutation.range, in: interface.string) else { return .none } - let insertedText = mutation.string - let fullText = interface.string - - // Check if the inserted text is a closing bracket (>) - if insertedText == ">" { - let textBeforeCursor = "\(String(fullText[..") { - let closingTag = "" - let newRange = NSRange(location: mutation.range.location + 1, length: 0) - DispatchQueue.main.async { - let newMutation = TextMutation(string: closingTag, range: newRange, limit: 50) - interface.applyMutation(newMutation) - let cursorPosition = NSRange(location: newRange.location, length: 0) - interface.selectedRange = cursorPosition - } + } + + /// Handles inserting a character after a tag. Determining if the tag should be completed and inserting the correct + /// closing tag string. + /// - Parameters: + /// - mutation: The mutation causing the lookup. + /// - interface: The interface to retrieve text from. + /// - whitespaceProvider: The whitespace provider to use for indentation. + /// - Returns: The length of the string inserted, if any string was inserted. + private func handleInsertionAfterTag( + _ mutation: TextMutation, + in interface: TextInterface, + with whitespaceProvider: WhitespaceProviders + ) -> Int? { + do { + guard let startTag = try findTagPairs(mutation, in: interface) else { return nil } + guard !Self.voidTags.contains(startTag) else { return nil } + + let closingTag = TextMutation( + string: "", + range: NSRange(location: mutation.range.max, length: 0), + limit: interface.length + ) + interface.applyMutation(closingTag) + interface.selectedRange = NSRange(location: mutation.range.max, length: 0) + return closingTag.string.utf16.count + } catch { + return nil + } + } + + // MARK: - tree-sitter Tree Querying + + /// Queries the tree-sitter syntax tree for necessary information for closing tags. + /// - Parameters: + /// - mutation: The mutation causing the lookup. + /// - interface: The interface to retrieve text from. + /// - Returns: A String representing the name of the start tag if found. If nil, abandon processing the tag. + func findTagPairs(_ mutation: TextMutation, in interface: TextInterface) throws -> String? { + // Find the tag name being completed. + guard let (foundStartTag, queryResult) = try getOpeningTagName(mutation, in: interface) else { + return nil + } + // Perform a query searching for the same tag, summing up opening and closing tags + let openQuery = try tagQuery( + queryResult.language, + id: queryResult.id, + tagName: foundStartTag, + opening: true, + openingTagId: queryResult.node.parent?.nodeType + ) + let closeQuery = try tagQuery( + queryResult.language, + id: queryResult.id, + tagName: foundStartTag, + opening: false, + openingTagId: queryResult.node.parent?.nodeType + ) + + let openTags = try treeSitterClient.query(openQuery, matchingLanguages: [.html, .jsx, .tsx]) + .flatMap { $0.cursor.flatMap { $0.captures(named: "name") } } + let closeTags = try treeSitterClient.query(closeQuery, matchingLanguages: [.html, .jsx, .tsx]) + .flatMap { $0.cursor.flatMap { $0.captures(named: "name") } } + + if openTags.count > closeTags.count { + return foundStartTag + } else { + return nil + } + } + + /// Build a query getting all matching tags for either opening or closing tags. + /// - Parameters: + /// - language: The language to query. + /// - id: The ID of the language. + /// - tagName: The name of the tag to query for. + /// - opening: True, if this should be querying for an opening tag. + /// - openingTagId: The ID of the opening tag if exists. + /// - Returns: A query to execute on a tree sitter tree, finding all matching nodes. + private func tagQuery( + _ language: Language, + id: TreeSitterLanguage, + tagName: String, + opening: Bool, + openingTagId: String? + ) throws -> Query { + let tagId = try tagId(for: id, opening: opening, openingTag: openingTagId) + let tagNameContents: String = try tagNameId(for: id) + let queryString = ("((" + tagId + " (" + tagNameContents + #") @name) (#eq? @name ""# + tagName + #""))"#) + guard let queryData = queryString.data(using: .utf8) else { + throw Self.Error.queryStringDataMissing + } + return try Query(language: language, data: queryData) + } + + /// Get the node ID for a tag in a language. + /// - Parameters: + /// - id: The language to get the ID for. + /// - opening: True, if querying the opening tag. + /// - openingTag: The ID of the original opening tag. + /// - Returns: The node ID for the given language and whether or not it's an opening or closing tag. + private func tagId(for id: TreeSitterLanguage, opening: Bool, openingTag: String?) throws -> String { + switch id { + case .html: + if opening { + return "start_tag" + } else { + return "end_tag" + } + case .jsx, .tsx: + if opening { + // Opening tag, match the given opening tag. + return openingTag ?? (id == .jsx ? "jsx_opening_element" : "tsx_opening_element") + } else if let openingTag { + // Closing tag, match the opening tag ID. + if openingTag == "jsx_opening_element" { + return "jsx_closing_element" + } else { + return "tsx_closing_element" } + } else { + throw Self.Error.invalidLanguage } + default: + throw Self.Error.invalidLanguage } + } - return .none + /// Get possible node IDs for a tag in a language. + /// - Parameters: + /// - id: The language to get the ID for. + /// - opening: True, if querying the opening tag. + /// - Returns: A set of possible node IDs for the language. + private func tagIds(for id: TreeSitterLanguage, opening: Bool) throws -> Set { + switch id { + case .html: + return [opening ? "start_tag" : "end_tag"] + case .jsx, .tsx: + return [ + opening ? "jsx_opening_element" : "jsx_closing_element", + opening ? "tsx_opening_element" : "tsx_closing_element" + ] + default: + throw Self.Error.invalidLanguage + } } - private func isRelevantLanguage() -> Bool { - let relevantLanguages = ["html", "javascript", "typescript", "jsx", "tsx"] - return relevantLanguages.contains(language) + + /// Get the name of the node that contains the tag's name. + /// - Parameter id: The language to get the name for. + /// - Returns: The node ID for a node that contains the tag's name. + private func tagNameId(for id: TreeSitterLanguage) throws -> String { + switch id { + case .html: + return "tag_name" + case .jsx, .tsx: + return "identifier" + default: + throw Self.Error.invalidLanguage + } } -} -private extension String { - var nearestTag: (name: String, isSelfClosing: Bool)? { - let regex = try? NSRegularExpression(pattern: "<([a-zA-Z0-9]+)([^>]*)>", options: .caseInsensitive) - let nsString = self as NSString - let results = regex?.matches(in: self, options: [], range: NSRange(location: 0, length: nsString.length)) - - // Find the nearest tag before the cursor - guard let lastMatch = results?.last(where: { $0.range.location < nsString.length }) else { return nil } - let tagNameRange = lastMatch.range(at: 1) - let attributesRange = lastMatch.range(at: 2) - let tagName = nsString.substring(with: tagNameRange) - let attributes = nsString.substring(with: attributesRange) - let isSelfClosing = attributes.contains("/") - - return (name: tagName, isSelfClosing: isSelfClosing) + + /// Gets the name of the opening tag to search for. + /// - Parameters: + /// - mutation: The mutation causing the search. + /// - interface: The interface to use for text content. + /// - Returns: The tag's name and the range of the matching node, if found. + private func getOpeningTagName( + _ mutation: TextMutation, + in interface: TextInterface + ) throws -> (String, TreeSitterClient.NodeResult)? { + let prevCharRange = NSRange(location: mutation.range.location - 1, length: 1) + let nodesAtLocation = try treeSitterClient.nodesAt(location: mutation.range.location - 1) + var foundStartTag: (String, TreeSitterClient.NodeResult)? + + for result in nodesAtLocation { + // Only attempt to process layers with the correct language. + guard result.id.shouldProcessTags() else { continue } + let tagIds = try tagIds(for: result.id, opening: true) + let tagNameId = try tagNameId(for: result.id) + // This node should represent the ">" character, grab its parent (the start tag node). + guard let node = result.node.parent else { continue } + guard node.byteRange.upperBound == UInt32(prevCharRange.max * 2), + tagIds.contains(node.nodeType ?? ""), + let tagNameNode = node.firstChild(where: { $0.nodeType == tagNameId }), + let tagName = interface.substring(from: tagNameNode.range) + else { + continue + } + + foundStartTag = ( + tagName, + result + ) + } + + return foundStartTag + } + + // MARK: - Newline Processing + + /// Processes a newline mutation, inserting the necessary newlines and indents after a tag closure. + /// Also places the selection position to the indented spot. + /// + /// Causes this interaction (where | is the cursor end location, X is the original location, and div was the tag + /// being completed): + /// ```html + ///
X + /// | + ///
+ /// ``` + /// + /// - Note: Must be called **after** the closing tag is inserted. + /// - Parameters: + /// - mutation: The mutation to process. + /// - interface: The interface to modify. + /// - whitespaceProvider: Provider used for getting whitespace from the interface. + /// - tagMutationLen: The length of the inserted tag mutation. + /// - Returns: The action to take for this mutation. + private func handleNewlineInsertion( + _ mutation: TextMutation, + in interface: TextInterface, + with whitespaceProvider: WhitespaceProviders, + tagMutationLen: Int + ) -> FilterAction { + guard let whitespaceRange = mutation.range.shifted(by: tagMutationLen + lineEnding.rawValue.utf16.count) else { + return .none + } + let whitespace = whitespaceProvider.leadingWhitespace(whitespaceRange, interface) + + // Should end up with (where | is the cursor and div was the tag being completed): + //
+ // | + //
+ let string = lineEnding.rawValue + whitespace + indentOption.stringValue + lineEnding.rawValue + whitespace + interface.insertString(string, at: mutation.range.max) + let offsetFromMutation = lineEnding.length + whitespace.utf16.count + indentOption.stringValue.utf16.count + interface.selectedRange = NSRange(location: mutation.range.max + offsetFromMutation, length: 0) + + return .discard } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift index 8d8444523..f5030b86b 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift @@ -13,6 +13,7 @@ public class LanguageLayer: Hashable { /// Initialize a language layer /// - Parameters: /// - id: The ID of the layer. + /// - tsLanguage: The tree sitter language reference. /// - 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. @@ -20,6 +21,7 @@ public class LanguageLayer: Hashable { /// - ranges: All ranges this layer acts on. Must be kept in order and w/o overlap. init( id: TreeSitterLanguage, + tsLanguage: Language?, parser: Parser, supportsInjections: Bool, tree: MutableTree? = nil, @@ -27,6 +29,7 @@ public class LanguageLayer: Hashable { ranges: [NSRange] ) { self.id = id + self.tsLanguage = tsLanguage self.parser = parser self.supportsInjections = supportsInjections self.tree = tree @@ -37,6 +40,7 @@ public class LanguageLayer: Hashable { } let id: TreeSitterLanguage + let tsLanguage: Language? let parser: Parser let supportsInjections: Bool var tree: MutableTree? @@ -46,6 +50,7 @@ public class LanguageLayer: Hashable { func copy() -> LanguageLayer { return LanguageLayer( id: id, + tsLanguage: tsLanguage, parser: parser, supportsInjections: supportsInjections, tree: tree?.mutableCopy(), diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift new file mode 100644 index 000000000..5502a7378 --- /dev/null +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -0,0 +1,72 @@ +// +// TreeSitterClient+Query.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/27/24. +// + +import Foundation +import CodeEditLanguages +import SwiftTreeSitter + +// Functions for querying and navigating the tree-sitter node tree. These functions should throw if not able to be +// performed asynchronously as (currently) any editing tasks that would use these must be performed synchronously. + +extension TreeSitterClient { + public struct NodeResult { + let id: TreeSitterLanguage + let language: Language + let node: Node + } + + public struct QueryResult { + let id: TreeSitterLanguage + let cursor: ResolvingQueryMatchSequence + } + + /// Finds nodes for each language layer at the given location. + /// - Parameter location: The location to get a node for. + /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree at the given location. + /// - Throws: A ``TreeSitterClient.Error`` error. + public func nodesAt(location: Int) throws -> [NodeResult] { + let range = NSRange(location: location, length: 1) + return try nodesAt(range: range) + } + + /// Finds nodes in each language layer for the given range. + /// - Parameter range: The range to get a node for. + /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree in the given range. + /// - Throws: A ``TreeSitterClient.Error`` error. + public func nodesAt(range: NSRange) throws -> [NodeResult] { + return try executor.performSync { + var nodes: [NodeResult] = [] + for layer in self.state?.layers ?? [] { + if let language = layer.tsLanguage, + let node = layer.tree?.rootNode?.descendant(in: range.tsRange.bytes) { + nodes.append(NodeResult(id: layer.id, language: language, node: node)) + } + } + return nodes + } + } + + /// Perform a query on the tree sitter layer tree. + /// - Parameters: + /// - query: The query to perform. + /// - matchingLanguages: A set of languages to limit the query to. Leave empty to not filter out any layers. + /// - Returns: Any matching nodes from the query. + public func query(_ query: Query, matchingLanguages: Set = []) throws -> [QueryResult] { + return try executor.performSync { + guard let readCallback = self.readCallback else { return [] } + var result: [QueryResult] = [] + for layer in self.state?.layers ?? [] { + guard matchingLanguages.isEmpty || matchingLanguages.contains(layer.id) else { continue } + guard let tree = layer.tree else { continue } + let cursor = query.execute(in: tree) + let resolvingCursor = cursor.resolve(with: Predicate.Context(textProvider: readCallback)) + result.append(QueryResult(id: layer.id, cursor: resolvingCursor)) + } + return result + } + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index cb5ea1f31..2f844f8db 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -19,29 +19,16 @@ import OSLog /// /// The APIs this object provides can perform either asynchronously or synchronously. All calls to this object must /// first be dispatched from the main queue to ensure serial access to internal properties. Any synchronous methods -/// can throw an ``TreeSitterClient/Error/syncUnavailable`` error if an asynchronous or synchronous call is already -/// being made on the object. In those cases it is up to the caller to decide whether or not to retry asynchronously. -/// +/// can throw an ``TreeSitterClientExecutor/Error/syncUnavailable`` error if an asynchronous or synchronous call is +/// already being made on the object. In those cases it is up to the caller to decide whether or not to retry +/// asynchronously. +/// /// The only exception to the above rule is the ``HighlightProviding`` conformance methods. The methods for that /// implementation may return synchronously or asynchronously depending on a variety of factors such as document /// length, edit length, highlight length and if the object is available for a synchronous call. public final class TreeSitterClient: HighlightProviding { static let logger: Logger = Logger(subsystem: "com.CodeEdit.CodeEditSourceEditor", category: "TreeSitterClient") - /// The number of operations running or enqueued to run on the dispatch queue. This variable **must** only be - /// changed from the main thread or race conditions are very likely. - private var runningOperationCount = 0 - - /// The number of times the object has been set up. Used to cancel async tasks if - /// ``TreeSitterClient/setUp(textView:codeLanguage:)`` is called. - private var setUpCount = 0 - - /// The concurrent queue to perform operations on. - private let operationQueue = DispatchQueue( - label: "CodeEditSourceEditor.TreeSitter.EditQueue", - qos: .userInteractive - ) - // MARK: - Properties /// A callback to use to efficiently fetch portions of text. @@ -53,6 +40,8 @@ public final class TreeSitterClient: HighlightProviding { /// The internal tree-sitter layer tree object. var state: TreeSitterState? + package var executor: TreeSitterClientExecutor + /// The end point of the previous edit. private var oldEndPoint: Point? @@ -81,8 +70,12 @@ public final class TreeSitterClient: HighlightProviding { static let maxSyncQueryLength: Int = 4096 } - public enum Error: Swift.Error { - case syncUnavailable + // MARK: - Init + + /// Initialize the tree sitter client. + /// - Parameter executor: The object to use when performing async/sync operations. + init(executor: TreeSitterClientExecutor = .init()) { + self.executor = executor } // MARK: - HighlightProviding @@ -115,71 +108,12 @@ public final class TreeSitterClient: HighlightProviding { readCallback: @escaping SwiftTreeSitter.Predicate.TextProvider, readBlock: @escaping Parser.ReadBlock ) { - setUpCount += 1 - performAsync { [weak self] in + executor.incrementSetupCount() + executor.performAsync { [weak self] in self?.state = TreeSitterState(codeLanguage: language, readCallback: readCallback, readBlock: readBlock) } } - // MARK: - Async Operations - - /// Performs the given operation asynchronously. - /// - /// All completion handlers passed to this function will be enqueued on the `operationQueue` dispatch queue, - /// ensuring serial access to this class. - /// - /// This function will handle ensuring balanced increment/decrements are made to the `runningOperationCount` in - /// a safe manner. - /// - /// - Note: While in debug mode, this method will throw an assertion failure if not called from the Main thread. - /// - Parameter operation: The operation to perform - private func performAsync(_ operation: @escaping () -> Void) { - assertMain() - runningOperationCount += 1 - let setUpCountCopy = setUpCount - operationQueue.async { [weak self] in - guard self != nil && self?.setUpCount == setUpCountCopy else { return } - operation() - DispatchQueue.main.async { - self?.runningOperationCount -= 1 - } - } - } - - /// Attempts to perform a synchronous operation on the client. - /// - /// The operation will be dispatched synchronously to the `operationQueue`, this function will return once the - /// operation is finished. - /// - /// - Note: While in debug mode, this method will throw an assertion failure if not called from the Main thread. - /// - Parameter operation: The operation to perform synchronously. - /// - Throws: Can throw an ``TreeSitterClient/Error/syncUnavailable`` error if it's determined that an async - /// operation is unsafe. - private func performSync(_ operation: @escaping () -> Void) throws { - assertMain() - - guard runningOperationCount == 0 else { - throw Error.syncUnavailable - } - - runningOperationCount += 1 - - operationQueue.sync { - operation() - } - - self.runningOperationCount -= 1 - } - - /// Assert that the caller is calling from the main thread. - private func assertMain() { -#if DEBUG - if !Thread.isMainThread { - assertionFailure("TreeSitterClient used from non-main queue. This will cause race conditions.") - } -#endif - } - // MARK: - HighlightProviding /// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted. @@ -210,7 +144,9 @@ public final class TreeSitterClient: HighlightProviding { let operation = { [weak self] in let invalidatedRanges = self?.applyEdit(edit: edit) ?? IndexSet() - completion(invalidatedRanges) + self?.executor.dispatchMain { + completion(invalidatedRanges) + } } do { @@ -218,11 +154,11 @@ public final class TreeSitterClient: HighlightProviding { let longDocument = textView.documentRange.length > Constants.maxSyncContentLength if longEdit || longDocument { - throw Error.syncUnavailable + throw TreeSitterClientExecutor.Error.syncUnavailable } - try performSync(operation) + try executor.performSync(operation) } catch { - performAsync(operation) + executor.performAsync(operation) } } @@ -242,7 +178,7 @@ public final class TreeSitterClient: HighlightProviding { ) { let operation = { [weak self] in let highlights = self?.queryHighlightsForRange(range: range) - DispatchQueue.main.async { + self?.executor.dispatchMain { completion(highlights ?? []) } } @@ -252,11 +188,11 @@ public final class TreeSitterClient: HighlightProviding { let longDocument = textView.documentRange.length > Constants.maxSyncContentLength if longQuery || longDocument { - throw Error.syncUnavailable + throw TreeSitterClientExecutor.Error.syncUnavailable } - try performSync(operation) + try executor.performSync(operation) } catch { - performAsync(operation) + executor.performAsync(operation) } } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClientExecutor.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClientExecutor.swift new file mode 100644 index 000000000..cf8b4c256 --- /dev/null +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClientExecutor.swift @@ -0,0 +1,132 @@ +// +// TreeSitterClientExecutor.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/30/24. +// + +import Foundation + +/// This class manages async/sync operations for the ``TreeSitterClient``. +/// +/// To force all operations to happen synchronously (for example, during testing), initialize this object setting the +/// `forceSync` parameter to true. +package class TreeSitterClientExecutor { + /// The error enum for ``TreeSitterClientExecutor`` + public enum Error: Swift.Error { + /// Thrown when an operation was not able to be performed asynchronously. + case syncUnavailable + } + + /// The number of operations running or enqueued to run on the dispatch queue. This variable **must** only be + /// changed from the main thread or race conditions are very likely. + private var runningOperationCount = 0 + + /// The number of times the object has been set up. Used to cancel async tasks if + /// ``TreeSitterClient/setUp(textView:codeLanguage:)`` is called. + private var setUpCount = 0 + + /// Set to true to force all operations to happen synchronously. Useful for testing. + private let forceSync: Bool + + /// The concurrent queue to perform operations on. + private let operationQueue = DispatchQueue( + label: "CodeEditSourceEditor.TreeSitter.EditQueue", + qos: .userInteractive + ) + + /// Initialize an executor. + /// - Parameter forceSync: Set to true to force all async operations to be performed synchronously. This will block + /// the main thread until every operation has completed. + init(forceSync: Bool = false) { + self.forceSync = forceSync + } + + package func incrementSetupCount() { + setUpCount += 1 + } + + /// Performs the given operation asynchronously. + /// + /// All completion handlers passed to this function will be enqueued on the `operationQueue` dispatch queue, + /// ensuring serial access to this class. + /// + /// This function will handle ensuring balanced increment/decrements are made to the `runningOperationCount` in + /// a safe manner. + /// + /// - Note: While in debug mode, this method will throw an assertion failure if not called from the Main thread. + /// - Parameter operation: The operation to perform + package func performAsync(_ operation: @escaping () -> Void) { + assertMain() + + guard !forceSync else { + try? performSync(operation) + return + } + + runningOperationCount += 1 + let setUpCountCopy = setUpCount + operationQueue.async { [weak self] in + guard self != nil && self?.setUpCount == setUpCountCopy else { return } + operation() + DispatchQueue.main.async { + self?.runningOperationCount -= 1 + } + } + } + + /// Attempts to perform a synchronous operation on the client. + /// + /// The operation will be dispatched synchronously to the `operationQueue`, this function will return once the + /// operation is finished. + /// + /// - Note: While in debug mode, this method will throw an assertion failure if not called from the Main thread. + /// - Parameter operation: The operation to perform synchronously. + /// - Throws: Can throw an ``TreeSitterClient/Error/syncUnavailable`` error if it's determined that an async + /// operation is unsafe. + package func performSync(_ operation: @escaping () throws -> T) throws -> T { + assertMain() + + guard runningOperationCount == 0 || forceSync else { + throw Error.syncUnavailable + } + + runningOperationCount += 1 + + let returnValue: T + if forceSync { + returnValue = try operation() + } else { + returnValue = try operationQueue.sync { + try operation() + } + } + + self.runningOperationCount -= 1 + + return returnValue + } + + /// Assert that the caller is calling from the main thread. + private func assertMain() { +#if DEBUG + if !Thread.isMainThread { + assertionFailure("TreeSitterClient used from non-main queue. This will cause race conditions.") + } +#endif + } + + /// Executes a task on the main thread. + /// If the caller is on the main thread already, executes it immediately. If not, it is queued + /// asynchronously for the main queue. + /// - Parameter task: The operation to execute. + package func dispatchMain(_ operation: @escaping () -> Void) { + if Thread.isMainThread { + operation() + } else { + DispatchQueue.main.async { + operation() + } + } + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift index 0508960e2..744c4a6b5 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterState.swift @@ -46,6 +46,7 @@ public class TreeSitterState { layers = [ LanguageLayer( id: codeLanguage.id, + tsLanguage: codeLanguage.language, parser: Parser(), supportsInjections: codeLanguage.additionalHighlights?.contains("injections") ?? false, tree: nil, @@ -117,6 +118,7 @@ public class TreeSitterState { let newLayer = LanguageLayer( id: layerId, + tsLanguage: language.language, parser: Parser(), supportsInjections: language.additionalHighlights?.contains("injections") ?? false, tree: nil, @@ -226,6 +228,7 @@ public class TreeSitterState { // Temp layer object let layer = LanguageLayer( id: treeSitterLanguage, + tsLanguage: nil, parser: Parser(), supportsInjections: false, ranges: [range.range] diff --git a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift new file mode 100644 index 000000000..499d13220 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import CodeEditSourceEditor +import SwiftTreeSitter +import AppKit +import SwiftUI + +// Tests for ensuring tag auto closing works. + +final class TagEditingTests: XCTestCase { + var controller: TextViewController! + var theme: EditorTheme! + var window: NSWindow! + + override func setUpWithError() throws { + theme = EditorTheme( + text: .textColor, + insertionPoint: .textColor, + invisibles: .gray, + background: .textBackgroundColor, + lineHighlight: .highlightColor, + selection: .selectedTextColor, + keywords: .systemPink, + commands: .systemBlue, + types: .systemMint, + attributes: .systemTeal, + variables: .systemCyan, + values: .systemOrange, + numbers: .systemYellow, + strings: .systemRed, + characters: .systemRed, + comments: .systemGreen + ) + controller = TextViewController( + string: "", + language: .html, + font: .monospacedSystemFont(ofSize: 11, weight: .medium), + theme: theme, + tabWidth: 4, + indentOption: .spaces(count: 4), + lineHeight: 1.0, + wrapLines: true, + cursorPositions: [], + editorOverscroll: 0.5, + useThemeBackground: true, + highlightProvider: nil, + contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + isEditable: true, + isSelectable: true, + letterSpacing: 1.0, + bracketPairHighlight: .flash + ) + let tsClient = TreeSitterClient(executor: .init(forceSync: true)) + controller.treeSitterClient = tsClient + controller.highlightProvider = tsClient + window = NSWindow() + window.contentViewController = controller + controller.loadView() + } + + func test_tagClose() { + controller.setText("
") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 26, length: 0)) + controller.textView.insertText(" ") + XCTAssertEqual(controller.textView.string, "
") + + controller.setText("

") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 25, length: 0)) + controller.textView.insertText("Header") + XCTAssertEqual(controller.textView.string, "

Header

") + + controller.setText("") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 40, length: 0)) + controller.textView.insertText("hello world!") + XCTAssertEqual( + controller.textView.string, + "hello world!" + ) + } + + func test_tagCloseWithNewline() { + controller.indentOption = .spaces(count: 4) + + controller.setText("\n
") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 21, length: 0)) + controller.textView.insertNewline(nil) + XCTAssertEqual(controller.textView.string, "\n
\n \n
") + + controller.setText("\n
\n") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 25, length: 0)) + controller.textView.insertNewline(nil) + XCTAssertEqual(controller.textView.string, "\n
\n \n
\n") + } + + func test_nestedClose() { + controller.indentOption = .spaces(count: 4) + + controller.setText("\n
\n
\n
\n") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 30, length: 0)) + controller.textView.insertNewline(nil) + XCTAssertEqual( + controller.textView.string, + "\n
\n
\n \n
\n
\n" + ) + XCTAssertEqual( + controller.cursorPositions[0], + CursorPosition(range: NSRange(location: 43, length: 0), line: 4, column: 13) + ) + } + + func test_tagNotClose() { + controller.indentOption = .spaces(count: 1) + + controller.setText("\n
\n
\n
\n") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) + controller.textView.insertNewline(nil) + XCTAssertEqual( + controller.textView.string, + "\n\n
\n
\n
\n" + ) + XCTAssertEqual( + controller.cursorPositions[0], + CursorPosition(range: NSRange(location: 7, length: 0), line: 2, column: 1) + ) + } + + func test_tagCloseWithAttributes() { + controller.setText("

") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 29, length: 0)) + controller.textView.insertText(" ") + XCTAssertEqual(controller.textView.string, "

") + } + + func test_JSXTagClose() { + controller.language = .jsx + controller.setText(""" + const name = "CodeEdit" + const element = ( +

+ Hello {name}! +

+

+ ); + """) + controller.textView.selectionManager.setSelectedRange(NSRange(location: 84, length: 0)) + controller.textView.insertText(" ") + // swifltint:disable:next trailing_whitespace + XCTAssertEqual( + controller.textView.string, + """ + const name = "CodeEdit" + const element = ( +

+ Hello {name}! +

+

+ ); + """ + ) + // swiflint:enable trailing_whitespace + } + + func test_TSXTagClose() { + controller.language = .tsx + controller.indentOption = .spaces(count: 4) + controller.setText(""" + const name = "CodeEdit" + const element = ( +

+ Hello {name}! +

+

+ ); + """) + controller.textView.selectionManager.setSelectedRange(NSRange(location: 84, length: 0)) + controller.textView.insertText(" ") + // swifltint:disable:next trailing_whitespace + XCTAssertEqual( + controller.textView.string, + """ + const name = "CodeEdit" + const element = ( +

+ Hello {name}! +

+

+ ); + """ + ) + // swiflint:enable trailing_whitespace + } +}