@@ -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.
0 commit comments