Skip to content

Commit

Permalink
autocomplete tags/components (#247)
Browse files Browse the repository at this point in the history
<!--- IMPORTANT: If this PR addresses multiple unrelated issues, it will
be closed until separated. -->

### Description

<!--- REQUIRED: Describe what changed in detail -->
Tags in HTML, JS, TS, JSX, and TSX are now autocompleted. When you type
`<div>` for example, the closing tag `</div>` will be autocompleted. If
you press enter, you will be put on a new line in between the opening
and closing and that new line will be indented. All tag attributes are
ignored in the closing tag.

### Related Issues


<!--- REQUIRED: Tag all related issues (e.g. * #123) -->
<!--- If this PR resolves the issue please specify (e.g. * closes #123)
-->
<!--- If this PR addresses multiple issues, these issues must be related
to one other -->

* closes #244

### Checklist

<!--- Add things that are not yet implemented above -->

- [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

<!--- REQUIRED: if issue is UI related -->


https://github.com/CodeEditApp/CodeEditSourceEditor/assets/118622417/cf9ffe27-7592-49d5-bee8-edacdd6ab5f4


<!--- IMPORTANT: Fill out all required fields. Otherwise we might close
this PR temporarily -->
  • Loading branch information
knotbin authored May 20, 2024
1 parent a666efd commit d2f655a
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public class TextViewController: NSViewController {
public var language: CodeLanguage {
didSet {
highlighter?.setLanguage(language: language)
updateTagFilter()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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: "</\(precedingTag)>", 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..<result.numberOfRanges).compactMap {
result.range(at: $0).location != NSNotFound ? nsString.substring(with: result.range(at: $0)) : nil
}
}
}
}
69 changes: 69 additions & 0 deletions Sources/CodeEditSourceEditor/Filters/TagFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// TagFilter.swift
//
//
// Created by Roscoe Rubin-Rottenberg on 5/18/24.
//

import Foundation
import TextFormation
import TextStory

struct TagFilter: Filter {
var language: String
private let newlineFilter = NewlineProcessingFilter()

func processMutation(
_ mutation: TextMutation,
in interface: TextInterface,
with whitespaceProvider: WhitespaceProviders
) -> 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[..<range.lowerBound]))\(insertedText)"
if let lastOpenTag = textBeforeCursor.nearestTag {
// Check if the tag is not self-closing and there isn't already a closing tag
if !lastOpenTag.isSelfClosing && !textBeforeCursor.contains("</\(lastOpenTag.name)>") {
let closingTag = "</\(lastOpenTag.name)>"
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)
}
}

0 comments on commit d2f655a

Please sign in to comment.