diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 40dae78ac..74b922ff0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -40,6 +40,7 @@ extension TextViewController { setUpNewlineTabFilters(indentOption: indentOption) setUpDeletePairFilters(pairs: BracketPairs.allValues) setUpDeleteWhitespaceFilter(indentOption: indentOption) + setUpTagFilter() } /// Returns a `TextualIndenter` based on available language configuration. @@ -90,6 +91,18 @@ extension TextViewController { textFilters.append(filter) } + 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)) + } + /// Determines whether or not a text mutation should be applied. /// - Parameters: /// - mutation: The text mutation. @@ -110,15 +123,30 @@ extension TextViewController { ) for filter in textFilters { - let action = filter.processMutation(mutation, in: textView, with: whitespaceProvider) - - switch action { - case .none: - break - case .stop: - return true - case .discard: - return false + 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 + } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 32a73f6fd..b1bf93b87 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -39,6 +39,7 @@ public class TextViewController: NSViewController { public var language: CodeLanguage { didSet { highlighter?.setLanguage(language: language) + updateTagFilter() } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NewlineProcessingFilter+TagHandling.swift b/Sources/CodeEditSourceEditor/Extensions/NewlineProcessingFilter+TagHandling.swift new file mode 100644 index 000000000..4df863af5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NewlineProcessingFilter+TagHandling.swift @@ -0,0 +1,102 @@ +// +// 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.. FilterAction { + guard isRelevantLanguage() 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 + } + } + } + } + + return .none + } + private func isRelevantLanguage() -> Bool { + let relevantLanguages = ["html", "javascript", "typescript", "jsx", "tsx"] + return relevantLanguages.contains(language) + } +} +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) + } +}