Skip to content

Commit 266db46

Browse files
authored
feat: auto-uppercase SQL keywords as you type (#660) (#664)
1 parent d508b4a commit 266db46

7 files changed

Lines changed: 143 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Auto-uppercase SQL keywords setting (#660)
13+
1014
## [0.30.0] - 2026-04-10
1115

1216
### Added

TablePro/Core/Autocomplete/SQLKeywords.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import Foundation
1111
enum SQLKeywords {
1212
// MARK: - Keywords
1313

14+
static let keywordSet: Set<String> = Set(keywords.filter { !$0.contains(" ") }.map { $0.lowercased() })
15+
1416
/// Primary SQL keywords
1517
static let keywords: [String] = [
1618
// DQL

TablePro/Models/Settings/EditorSettings.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,16 @@ struct EditorSettings: Codable, Equatable {
6969
var autoIndent: Bool
7070
var wordWrap: Bool
7171
var vimModeEnabled: Bool
72+
var uppercaseKeywords: Bool
7273

7374
static let `default` = EditorSettings(
7475
showLineNumbers: true,
7576
highlightCurrentLine: true,
7677
tabWidth: 4,
7778
autoIndent: true,
7879
wordWrap: false,
79-
vimModeEnabled: false
80+
vimModeEnabled: false,
81+
uppercaseKeywords: false
8082
)
8183

8284
init(
@@ -85,14 +87,16 @@ struct EditorSettings: Codable, Equatable {
8587
tabWidth: Int = 4,
8688
autoIndent: Bool = true,
8789
wordWrap: Bool = false,
88-
vimModeEnabled: Bool = false
90+
vimModeEnabled: Bool = false,
91+
uppercaseKeywords: Bool = false
8992
) {
9093
self.showLineNumbers = showLineNumbers
9194
self.highlightCurrentLine = highlightCurrentLine
9295
self.tabWidth = tabWidth
9396
self.autoIndent = autoIndent
9497
self.wordWrap = wordWrap
9598
self.vimModeEnabled = vimModeEnabled
99+
self.uppercaseKeywords = uppercaseKeywords
96100
}
97101

98102
init(from decoder: Decoder) throws {
@@ -104,6 +108,7 @@ struct EditorSettings: Codable, Equatable {
104108
autoIndent = try container.decodeIfPresent(Bool.self, forKey: .autoIndent) ?? true
105109
wordWrap = try container.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? false
106110
vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false
111+
uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false
107112
}
108113

109114
/// Clamped tab width (1-16)

TablePro/Resources/Localizable.xcstrings

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5282,6 +5282,9 @@
52825282
}
52835283
}
52845284
}
5285+
},
5286+
"Auto-uppercase keywords" : {
5287+
52855288
},
52865289
"Automatically check for updates" : {
52875290
"localizations" : {

TablePro/Views/Editor/SQLEditorCoordinator.swift

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import os
1515
/// Coordinator for the SQL editor — manages find panel, horizontal scrolling, and scroll-to-match
1616
@Observable
1717
@MainActor
18-
final class SQLEditorCoordinator: TextViewCoordinator {
18+
final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
1919
// MARK: - Properties
2020

2121
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorCoordinator")
@@ -32,6 +32,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {
3232
/// Debounce work item for frame-change notification to avoid
3333
/// triggering syntax highlight viewport recalculation on every keystroke.
3434
@ObservationIgnored private var frameChangeTask: Task<Void, Never>?
35+
@ObservationIgnored private var isUppercasing = false
3536
@ObservationIgnored private var wasEditorFocused = false
3637
@ObservationIgnored private var didDestroy = false
3738

@@ -121,25 +122,22 @@ final class SQLEditorCoordinator: TextViewCoordinator {
121122
}
122123
}
123124

124-
func textViewDidChangeText(controller: TextViewController) {
125-
// Invalidate Vim buffer's cached line count after text changes
125+
func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) {
126126
vimEngine?.invalidateLineCache()
127127

128-
// Notify inline suggestion manager immediately (lightweight)
129128
Task { [weak self] in
130129
self?.inlineSuggestionManager?.handleTextChange()
131130
self?.vimCursorManager?.updatePosition()
132131
}
133132

134-
// Throttle frame-change notification — during rapid typing, only the
135-
// last notification matters. The highlighter recalculates the visible
136-
// range on each notification, so coalescing saves redundant layout work.
137133
frameChangeTask?.cancel()
138134
frameChangeTask = Task { [weak controller] in
139135
try? await Task.sleep(for: .milliseconds(50))
140136
guard !Task.isCancelled, let controller, let textView = controller.textView else { return }
141137
NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: textView)
142138
}
139+
140+
uppercaseKeywordIfNeeded(textView: textView, range: range, string: string)
143141
}
144142

145143
func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) {
@@ -350,6 +348,118 @@ final class SQLEditorCoordinator: TextViewCoordinator {
350348
}
351349
}
352350

351+
// MARK: - Keyword Auto-Uppercase
352+
353+
private func uppercaseKeywordIfNeeded(textView: TextView, range: NSRange, string: String) {
354+
guard !isUppercasing,
355+
AppSettingsManager.shared.editor.uppercaseKeywords,
356+
isWordBoundary(string) else { return }
357+
358+
let nsText = textView.textStorage.string as NSString
359+
let wordEnd = range.location
360+
361+
var wordStart = wordEnd
362+
while wordStart > 0 {
363+
let ch = nsText.character(at: wordStart - 1)
364+
guard isWordCharacter(ch) else { break }
365+
wordStart -= 1
366+
}
367+
368+
let wordLength = wordEnd - wordStart
369+
guard wordLength > 0 else { return }
370+
371+
let word = nsText.substring(with: NSRange(location: wordStart, length: wordLength))
372+
guard SQLKeywords.keywordSet.contains(word.lowercased()) else { return }
373+
guard !isInsideProtectedContext(nsText, at: wordStart) else { return }
374+
375+
let uppercased = word.uppercased()
376+
guard uppercased != word else { return }
377+
378+
// Mutate textStorage directly — we're inside beginEditing/endEditing
379+
// so NSTextStorage consolidates this with the delimiter insertion.
380+
// Cannot use textView.replaceCharacters here because CEUndoManager's
381+
// registerMutation calls inverseMutation which asserts mid-edit.
382+
let wordRange = NSRange(location: wordStart, length: wordLength)
383+
isUppercasing = true
384+
textView.textStorage.replaceCharacters(in: wordRange, with: uppercased)
385+
textView.selectionManager.didReplaceCharacters(in: wordRange, replacementLength: wordLength)
386+
isUppercasing = false
387+
}
388+
389+
private func isWordBoundary(_ string: String) -> Bool {
390+
guard (string as NSString).length == 1, let ch = string.unicodeScalars.first else { return false }
391+
switch ch {
392+
case " ", "\t", "\n", "\r", "(", ")", ",", ";":
393+
return true
394+
default:
395+
return false
396+
}
397+
}
398+
399+
private func isWordCharacter(_ ch: unichar) -> Bool {
400+
(ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) ||
401+
(ch >= 0x30 && ch <= 0x39) || ch == 0x5F
402+
}
403+
404+
private func isInsideProtectedContext(_ text: NSString, at position: Int) -> Bool {
405+
let scanStart = max(0, position - 2000)
406+
var inSingleQuote = false
407+
var inDoubleQuote = false
408+
var inBacktick = false
409+
var inLineComment = false
410+
var inBlockComment = false
411+
var i = scanStart
412+
413+
while i < position {
414+
let ch = text.character(at: i)
415+
416+
if inBlockComment {
417+
if ch == 0x2A && i + 1 < position && text.character(at: i + 1) == 0x2F {
418+
inBlockComment = false
419+
i += 2
420+
continue
421+
}
422+
i += 1
423+
continue
424+
}
425+
if inLineComment {
426+
if ch == 0x0A { inLineComment = false }
427+
i += 1
428+
continue
429+
}
430+
431+
// Skip backslash-escaped characters (e.g. \' inside strings)
432+
if ch == 0x5C && (inSingleQuote || inDoubleQuote) {
433+
i += 2
434+
continue
435+
}
436+
437+
switch ch {
438+
case 0x27: if !inDoubleQuote && !inBacktick { inSingleQuote.toggle() }
439+
case 0x22: if !inSingleQuote && !inBacktick { inDoubleQuote.toggle() }
440+
case 0x60: if !inSingleQuote && !inDoubleQuote { inBacktick.toggle() }
441+
case 0x2D:
442+
if !inSingleQuote && !inDoubleQuote && !inBacktick &&
443+
i + 1 < position && text.character(at: i + 1) == 0x2D {
444+
inLineComment = true
445+
i += 2
446+
continue
447+
}
448+
case 0x2F:
449+
if !inSingleQuote && !inDoubleQuote && !inBacktick &&
450+
i + 1 < position && text.character(at: i + 1) == 0x2A {
451+
inBlockComment = true
452+
i += 2
453+
continue
454+
}
455+
default: break
456+
}
457+
i += 1
458+
}
459+
460+
return inSingleQuote || inDoubleQuote || inBacktick || inLineComment || inBlockComment
461+
}
462+
353463
// MARK: - CodeEditSourceEditor Workarounds
354464

355465
/// Reorder FindViewController's subviews so the find panel is on top for hit testing.

TablePro/Views/Settings/EditorSettingsView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct EditorSettingsView: View {
2626
Text("8 spaces").tag(8)
2727
}
2828
Toggle("Vim mode", isOn: $settings.vimModeEnabled)
29+
Toggle("Auto-uppercase keywords", isOn: $settings.uppercaseKeywords)
2930
}
3031
}
3132
.formStyle(.grouped)

docs/customization/editor-settings.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ SELECT
209209

210210
## Editor Behavior
211211

212+
### Auto-Uppercase Keywords
213+
214+
| Option | Description |
215+
|--------|-------------|
216+
| **Off** | Keywords stay as typed (default) |
217+
| **On** | SQL keywords auto-uppercase when you type a space or delimiter |
218+
219+
When enabled, recognized SQL keywords (`select`, `from`, `where`, `join`, etc.) are converted to uppercase as soon as you type a word boundary character (space, tab, newline, parenthesis, comma, or semicolon). Keywords inside strings, comments, and backtick-quoted identifiers are left unchanged.
220+
212221
### Autocomplete
213222

214223
Autocomplete is always on. Dismiss with `Escape`. See [Autocomplete](/features/autocomplete) for details.

0 commit comments

Comments
 (0)