Skip to content

Commit d2f655a

Browse files
authored
autocomplete tags/components (#247)
<!--- 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 -->
1 parent a666efd commit d2f655a

File tree

4 files changed

+209
-9
lines changed

4 files changed

+209
-9
lines changed

Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift

+37-9
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ extension TextViewController {
4040
setUpNewlineTabFilters(indentOption: indentOption)
4141
setUpDeletePairFilters(pairs: BracketPairs.allValues)
4242
setUpDeleteWhitespaceFilter(indentOption: indentOption)
43+
setUpTagFilter()
4344
}
4445

4546
/// Returns a `TextualIndenter` based on available language configuration.
@@ -90,6 +91,18 @@ extension TextViewController {
9091
textFilters.append(filter)
9192
}
9293

94+
private func setUpTagFilter() {
95+
let filter = TagFilter(language: self.language.tsName)
96+
textFilters.append(filter)
97+
}
98+
99+
func updateTagFilter() {
100+
textFilters.removeAll { $0 is TagFilter }
101+
102+
// Add new tagfilter with the updated language
103+
textFilters.append(TagFilter(language: self.language.tsName))
104+
}
105+
93106
/// Determines whether or not a text mutation should be applied.
94107
/// - Parameters:
95108
/// - mutation: The text mutation.
@@ -110,15 +123,30 @@ extension TextViewController {
110123
)
111124

112125
for filter in textFilters {
113-
let action = filter.processMutation(mutation, in: textView, with: whitespaceProvider)
114-
115-
switch action {
116-
case .none:
117-
break
118-
case .stop:
119-
return true
120-
case .discard:
121-
return false
126+
if let newlineFilter = filter as? NewlineProcessingFilter {
127+
let action = mutation.applyWithTagProcessing(
128+
in: textView,
129+
using: newlineFilter,
130+
with: whitespaceProvider, indentOption: indentOption
131+
)
132+
switch action {
133+
case .none:
134+
continue
135+
case .stop:
136+
return true
137+
case .discard:
138+
return false
139+
}
140+
} else {
141+
let action = filter.processMutation(mutation, in: textView, with: whitespaceProvider)
142+
switch action {
143+
case .none:
144+
continue
145+
case .stop:
146+
return true
147+
case .discard:
148+
return false
149+
}
122150
}
123151
}
124152

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public class TextViewController: NSViewController {
3939
public var language: CodeLanguage {
4040
didSet {
4141
highlighter?.setLanguage(language: language)
42+
updateTagFilter()
4243
}
4344
}
4445

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//
2+
// NewlineProcessingFilter+TagHandling.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Roscoe Rubin-Rottenberg on 5/19/24.
6+
//
7+
8+
import Foundation
9+
import TextStory
10+
import TextFormation
11+
12+
extension NewlineProcessingFilter {
13+
14+
private func handleTags(
15+
for mutation: TextMutation,
16+
in interface: TextInterface,
17+
with indentOption: IndentOption
18+
) -> Bool {
19+
guard let precedingText = interface.substring(
20+
from: NSRange(
21+
location: 0,
22+
length: mutation.range.location
23+
)
24+
) else {
25+
return false
26+
}
27+
28+
guard let followingText = interface.substring(
29+
from: NSRange(
30+
location: mutation.range.location,
31+
length: interface.length - mutation.range.location
32+
)
33+
) else {
34+
return false
35+
}
36+
37+
let tagPattern = "<([a-zA-Z][a-zA-Z0-9]*)\\b[^>]*>"
38+
39+
guard let precedingTagGroups = precedingText.groups(for: tagPattern),
40+
let precedingTag = precedingTagGroups.first else {
41+
return false
42+
}
43+
44+
guard followingText.range(of: "</\(precedingTag)>", options: .regularExpression) != nil else {
45+
return false
46+
}
47+
48+
let insertionLocation = mutation.range.location
49+
let newline = "\n"
50+
let indentedNewline = newline + indentOption.stringValue
51+
let newRange = NSRange(location: insertionLocation + indentedNewline.count, length: 0)
52+
53+
// Insert indented newline first
54+
interface.insertString(indentedNewline, at: insertionLocation)
55+
// Then insert regular newline after indented newline
56+
interface.insertString(newline, at: insertionLocation + indentedNewline.count)
57+
interface.selectedRange = newRange
58+
59+
return true
60+
}
61+
62+
public func processTags(
63+
for mutation: TextMutation,
64+
in interface: TextInterface,
65+
with indentOption: IndentOption
66+
) -> FilterAction {
67+
if handleTags(for: mutation, in: interface, with: indentOption) {
68+
return .discard
69+
}
70+
return .none
71+
}
72+
}
73+
74+
public extension TextMutation {
75+
func applyWithTagProcessing(
76+
in interface: TextInterface,
77+
using filter: NewlineProcessingFilter,
78+
with providers: WhitespaceProviders,
79+
indentOption: IndentOption
80+
) -> FilterAction {
81+
if filter.processTags(for: self, in: interface, with: indentOption) == .discard {
82+
return .discard
83+
}
84+
85+
// Apply the original filter processing
86+
return filter.processMutation(self, in: interface, with: providers)
87+
}
88+
}
89+
90+
// Helper extension to extract capture groups
91+
extension String {
92+
func groups(for regexPattern: String) -> [String]? {
93+
guard let regex = try? NSRegularExpression(pattern: regexPattern) else { return nil }
94+
let nsString = self as NSString
95+
let results = regex.matches(in: self, range: NSRange(location: 0, length: nsString.length))
96+
return results.first.map { result in
97+
(1..<result.numberOfRanges).compactMap {
98+
result.range(at: $0).location != NSNotFound ? nsString.substring(with: result.range(at: $0)) : nil
99+
}
100+
}
101+
}
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// TagFilter.swift
3+
//
4+
//
5+
// Created by Roscoe Rubin-Rottenberg on 5/18/24.
6+
//
7+
8+
import Foundation
9+
import TextFormation
10+
import TextStory
11+
12+
struct TagFilter: Filter {
13+
var language: String
14+
private let newlineFilter = NewlineProcessingFilter()
15+
16+
func processMutation(
17+
_ mutation: TextMutation,
18+
in interface: TextInterface,
19+
with whitespaceProvider: WhitespaceProviders
20+
) -> FilterAction {
21+
guard isRelevantLanguage() else {
22+
return .none
23+
}
24+
guard let range = Range(mutation.range, in: interface.string) else { return .none }
25+
let insertedText = mutation.string
26+
let fullText = interface.string
27+
28+
// Check if the inserted text is a closing bracket (>)
29+
if insertedText == ">" {
30+
let textBeforeCursor = "\(String(fullText[..<range.lowerBound]))\(insertedText)"
31+
if let lastOpenTag = textBeforeCursor.nearestTag {
32+
// Check if the tag is not self-closing and there isn't already a closing tag
33+
if !lastOpenTag.isSelfClosing && !textBeforeCursor.contains("</\(lastOpenTag.name)>") {
34+
let closingTag = "</\(lastOpenTag.name)>"
35+
let newRange = NSRange(location: mutation.range.location + 1, length: 0)
36+
DispatchQueue.main.async {
37+
let newMutation = TextMutation(string: closingTag, range: newRange, limit: 50)
38+
interface.applyMutation(newMutation)
39+
let cursorPosition = NSRange(location: newRange.location, length: 0)
40+
interface.selectedRange = cursorPosition
41+
}
42+
}
43+
}
44+
}
45+
46+
return .none
47+
}
48+
private func isRelevantLanguage() -> Bool {
49+
let relevantLanguages = ["html", "javascript", "typescript", "jsx", "tsx"]
50+
return relevantLanguages.contains(language)
51+
}
52+
}
53+
private extension String {
54+
var nearestTag: (name: String, isSelfClosing: Bool)? {
55+
let regex = try? NSRegularExpression(pattern: "<([a-zA-Z0-9]+)([^>]*)>", options: .caseInsensitive)
56+
let nsString = self as NSString
57+
let results = regex?.matches(in: self, options: [], range: NSRange(location: 0, length: nsString.length))
58+
59+
// Find the nearest tag before the cursor
60+
guard let lastMatch = results?.last(where: { $0.range.location < nsString.length }) else { return nil }
61+
let tagNameRange = lastMatch.range(at: 1)
62+
let attributesRange = lastMatch.range(at: 2)
63+
let tagName = nsString.substring(with: tagNameRange)
64+
let attributes = nsString.substring(with: attributesRange)
65+
let isSelfClosing = attributes.contains("/")
66+
67+
return (name: tagName, isSelfClosing: isSelfClosing)
68+
}
69+
}

0 commit comments

Comments
 (0)