diff --git a/CHANGELOG.md b/CHANGELOG.md index 80353386..a562a838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Auto-uppercase SQL keywords setting (#660) + ## [0.30.0] - 2026-04-10 ### Added diff --git a/TablePro/Core/Autocomplete/SQLKeywords.swift b/TablePro/Core/Autocomplete/SQLKeywords.swift index 4c9d21a2..aef7b211 100644 --- a/TablePro/Core/Autocomplete/SQLKeywords.swift +++ b/TablePro/Core/Autocomplete/SQLKeywords.swift @@ -11,6 +11,8 @@ import Foundation enum SQLKeywords { // MARK: - Keywords + static let keywordSet: Set = Set(keywords.filter { !$0.contains(" ") }.map { $0.lowercased() }) + /// Primary SQL keywords static let keywords: [String] = [ // DQL diff --git a/TablePro/Models/Settings/EditorSettings.swift b/TablePro/Models/Settings/EditorSettings.swift index 8e3d4845..7890794f 100644 --- a/TablePro/Models/Settings/EditorSettings.swift +++ b/TablePro/Models/Settings/EditorSettings.swift @@ -69,6 +69,7 @@ struct EditorSettings: Codable, Equatable { var autoIndent: Bool var wordWrap: Bool var vimModeEnabled: Bool + var uppercaseKeywords: Bool static let `default` = EditorSettings( showLineNumbers: true, @@ -76,7 +77,8 @@ struct EditorSettings: Codable, Equatable { tabWidth: 4, autoIndent: true, wordWrap: false, - vimModeEnabled: false + vimModeEnabled: false, + uppercaseKeywords: false ) init( @@ -85,7 +87,8 @@ struct EditorSettings: Codable, Equatable { tabWidth: Int = 4, autoIndent: Bool = true, wordWrap: Bool = false, - vimModeEnabled: Bool = false + vimModeEnabled: Bool = false, + uppercaseKeywords: Bool = false ) { self.showLineNumbers = showLineNumbers self.highlightCurrentLine = highlightCurrentLine @@ -93,6 +96,7 @@ struct EditorSettings: Codable, Equatable { self.autoIndent = autoIndent self.wordWrap = wordWrap self.vimModeEnabled = vimModeEnabled + self.uppercaseKeywords = uppercaseKeywords } init(from decoder: Decoder) throws { @@ -104,6 +108,7 @@ struct EditorSettings: Codable, Equatable { autoIndent = try container.decodeIfPresent(Bool.self, forKey: .autoIndent) ?? true wordWrap = try container.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? false vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false + uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false } /// Clamped tab width (1-16) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 89482c58..bb2a9d57 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -5282,6 +5282,9 @@ } } } + }, + "Auto-uppercase keywords" : { + }, "Automatically check for updates" : { "localizations" : { diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 568fd4bc..daa9477c 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -15,7 +15,7 @@ import os /// Coordinator for the SQL editor — manages find panel, horizontal scrolling, and scroll-to-match @Observable @MainActor -final class SQLEditorCoordinator: TextViewCoordinator { +final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { // MARK: - Properties private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorCoordinator") @@ -32,6 +32,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { /// Debounce work item for frame-change notification to avoid /// triggering syntax highlight viewport recalculation on every keystroke. @ObservationIgnored private var frameChangeTask: Task? + @ObservationIgnored private var isUppercasing = false @ObservationIgnored private var wasEditorFocused = false @ObservationIgnored private var didDestroy = false @@ -121,25 +122,22 @@ final class SQLEditorCoordinator: TextViewCoordinator { } } - func textViewDidChangeText(controller: TextViewController) { - // Invalidate Vim buffer's cached line count after text changes + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { vimEngine?.invalidateLineCache() - // Notify inline suggestion manager immediately (lightweight) Task { [weak self] in self?.inlineSuggestionManager?.handleTextChange() self?.vimCursorManager?.updatePosition() } - // Throttle frame-change notification — during rapid typing, only the - // last notification matters. The highlighter recalculates the visible - // range on each notification, so coalescing saves redundant layout work. frameChangeTask?.cancel() frameChangeTask = Task { [weak controller] in try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled, let controller, let textView = controller.textView else { return } NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: textView) } + + uppercaseKeywordIfNeeded(textView: textView, range: range, string: string) } func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { @@ -350,6 +348,118 @@ final class SQLEditorCoordinator: TextViewCoordinator { } } + // MARK: - Keyword Auto-Uppercase + + private func uppercaseKeywordIfNeeded(textView: TextView, range: NSRange, string: String) { + guard !isUppercasing, + AppSettingsManager.shared.editor.uppercaseKeywords, + isWordBoundary(string) else { return } + + let nsText = textView.textStorage.string as NSString + let wordEnd = range.location + + var wordStart = wordEnd + while wordStart > 0 { + let ch = nsText.character(at: wordStart - 1) + guard isWordCharacter(ch) else { break } + wordStart -= 1 + } + + let wordLength = wordEnd - wordStart + guard wordLength > 0 else { return } + + let word = nsText.substring(with: NSRange(location: wordStart, length: wordLength)) + guard SQLKeywords.keywordSet.contains(word.lowercased()) else { return } + guard !isInsideProtectedContext(nsText, at: wordStart) else { return } + + let uppercased = word.uppercased() + guard uppercased != word else { return } + + // Mutate textStorage directly — we're inside beginEditing/endEditing + // so NSTextStorage consolidates this with the delimiter insertion. + // Cannot use textView.replaceCharacters here because CEUndoManager's + // registerMutation calls inverseMutation which asserts mid-edit. + let wordRange = NSRange(location: wordStart, length: wordLength) + isUppercasing = true + textView.textStorage.replaceCharacters(in: wordRange, with: uppercased) + textView.selectionManager.didReplaceCharacters(in: wordRange, replacementLength: wordLength) + isUppercasing = false + } + + private func isWordBoundary(_ string: String) -> Bool { + guard (string as NSString).length == 1, let ch = string.unicodeScalars.first else { return false } + switch ch { + case " ", "\t", "\n", "\r", "(", ")", ",", ";": + return true + default: + return false + } + } + + private func isWordCharacter(_ ch: unichar) -> Bool { + (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) || + (ch >= 0x30 && ch <= 0x39) || ch == 0x5F + } + + private func isInsideProtectedContext(_ text: NSString, at position: Int) -> Bool { + let scanStart = max(0, position - 2000) + var inSingleQuote = false + var inDoubleQuote = false + var inBacktick = false + var inLineComment = false + var inBlockComment = false + var i = scanStart + + while i < position { + let ch = text.character(at: i) + + if inBlockComment { + if ch == 0x2A && i + 1 < position && text.character(at: i + 1) == 0x2F { + inBlockComment = false + i += 2 + continue + } + i += 1 + continue + } + if inLineComment { + if ch == 0x0A { inLineComment = false } + i += 1 + continue + } + + // Skip backslash-escaped characters (e.g. \' inside strings) + if ch == 0x5C && (inSingleQuote || inDoubleQuote) { + i += 2 + continue + } + + switch ch { + case 0x27: if !inDoubleQuote && !inBacktick { inSingleQuote.toggle() } + case 0x22: if !inSingleQuote && !inBacktick { inDoubleQuote.toggle() } + case 0x60: if !inSingleQuote && !inDoubleQuote { inBacktick.toggle() } + case 0x2D: + if !inSingleQuote && !inDoubleQuote && !inBacktick && + i + 1 < position && text.character(at: i + 1) == 0x2D { + inLineComment = true + i += 2 + continue + } + case 0x2F: + if !inSingleQuote && !inDoubleQuote && !inBacktick && + i + 1 < position && text.character(at: i + 1) == 0x2A { + inBlockComment = true + i += 2 + continue + } + default: break + } + i += 1 + } + + return inSingleQuote || inDoubleQuote || inBacktick || inLineComment || inBlockComment + } + // MARK: - CodeEditSourceEditor Workarounds /// Reorder FindViewController's subviews so the find panel is on top for hit testing. diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index fd11fb0b..bdccf0cd 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -26,6 +26,7 @@ struct EditorSettingsView: View { Text("8 spaces").tag(8) } Toggle("Vim mode", isOn: $settings.vimModeEnabled) + Toggle("Auto-uppercase keywords", isOn: $settings.uppercaseKeywords) } } .formStyle(.grouped) diff --git a/docs/customization/editor-settings.mdx b/docs/customization/editor-settings.mdx index 63f02426..b54f9b80 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -209,6 +209,15 @@ SELECT ## Editor Behavior +### Auto-Uppercase Keywords + +| Option | Description | +|--------|-------------| +| **Off** | Keywords stay as typed (default) | +| **On** | SQL keywords auto-uppercase when you type a space or delimiter | + +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. + ### Autocomplete Autocomplete is always on. Dismiss with `Escape`. See [Autocomplete](/features/autocomplete) for details.