Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions TablePro/Core/Autocomplete/SQLKeywords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ enum SQLKeywords {
"CREATE", "ALTER", "DROP", "RENAME", "MODIFY",
"TABLE", "VIEW", "INDEX", "DATABASE", "SCHEMA",
"COLUMN", "CONSTRAINT", "PRIMARY", "FOREIGN", "KEY",
"REFERENCES", "UNIQUE", "CHECK", "DEFAULT",
"REFERENCES", "UNIQUE", "CHECK",
"AUTO_INCREMENT", "AUTOINCREMENT", "SERIAL",

// Data types (common)
Expand Down Expand Up @@ -85,7 +85,7 @@ enum SQLKeywords {
"DEALLOCATE", "PREPARE", "EXECUTE",

// Other
"WITH", "RECURSIVE", "TEMPORARY", "TEMP", "IF",
"WITH", "RECURSIVE", "TEMPORARY", "TEMP",
"CASCADE", "RESTRICT", "NO", "ACTION",
"EXPLAIN", "ANALYZE", "DESCRIBE", "SHOW"
]
Expand Down
128 changes: 128 additions & 0 deletions TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

/// Pure helper functions for SQL keyword auto-uppercase.
/// Extracted from SQLEditorCoordinator for testability.
enum KeywordUppercaseHelper {

/// Checks if a typed string is a word boundary character (triggers keyword check).
static 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
}
}

/// Checks if a UTF-16 character is part of a SQL identifier (a-z, A-Z, 0-9, _).
static func isWordCharacter(_ ch: unichar) -> Bool {
(ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) ||
(ch >= 0x30 && ch <= 0x39) || ch == 0x5F
}

/// Scans backwards up to 2,000 characters to determine if `position` is inside
/// a protected context (string literal, comment, backtick identifier, dollar-quote).
/// Keywords inside protected contexts should NOT be uppercased.
static func isInsideProtectedContext(_ text: NSString, at position: Int) -> Bool {
let scanStart = max(0, position - 2_000)
var inSingleQuote = false
var inDoubleQuote = false
var inBacktick = false
var inLineComment = false
var inBlockComment = false
var inDollarQuote = 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
}
if inDollarQuote {
if ch == 0x24 && i + 1 < position && text.character(at: i + 1) == 0x24 {
inDollarQuote = false
i += 2
continue
}
i += 1
continue
}

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 0x23:
if !inSingleQuote && !inDoubleQuote && !inBacktick {
inLineComment = true
}
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
}
case 0x24:
if !inSingleQuote && !inDoubleQuote && !inBacktick &&
i + 1 < position && text.character(at: i + 1) == 0x24 {
inDollarQuote.toggle()
i += 2
continue
}
default: break
}
i += 1
}

return inSingleQuote || inDoubleQuote || inBacktick || inLineComment || inBlockComment || inDollarQuote
}

/// Extracts the word immediately before `position` in `text` by scanning backwards.
/// Returns nil if no word found or the word is not a SQL keyword.
static func keywordBeforePosition(_ text: NSString, at position: Int) -> (word: String, range: NSRange)? {
var wordStart = position
while wordStart > 0 {
let ch = text.character(at: wordStart - 1)
guard isWordCharacter(ch) else { break }
wordStart -= 1
}

let wordLength = position - wordStart
guard wordLength > 0 else { return nil }

let word = text.substring(with: NSRange(location: wordStart, length: wordLength))
guard SQLKeywords.keywordSet.contains(word.lowercased()) else { return nil }
guard !isInsideProtectedContext(text, at: wordStart) else { return nil }

let uppercased = word.uppercased()
guard uppercased != word else { return nil }

return (word: word, range: NSRange(location: wordStart, length: wordLength))
}
}
121 changes: 26 additions & 95 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,111 +353,42 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
private func uppercaseKeywordIfNeeded(textView: TextView, range: NSRange, string: String) {
guard !isUppercasing,
AppSettingsManager.shared.editor.uppercaseKeywords,
isWordBoundary(string) else { return }
KeywordUppercaseHelper.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 }
guard let match = KeywordUppercaseHelper.keywordBeforePosition(nsText, at: range.location) else { return }

let word = match.word
let wordRange = match.range
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 - 2_000)
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
DispatchQueue.main.async { [weak self, weak textView] in
guard let self, let textView, !self.didDestroy else {
self?.isUppercasing = false
return
}
if inLineComment {
if ch == 0x0A { inLineComment = false }
i += 1
continue
guard wordRange.upperBound <= textView.textStorage.length else {
self.isUppercasing = false
return
}

// Skip backslash-escaped characters (e.g. \' inside strings)
if ch == 0x5C && (inSingleQuote || inDoubleQuote) {
i += 2
continue
let currentWord = (textView.textStorage.string as NSString).substring(with: wordRange)
guard currentWord == word else {
self.isUppercasing = false
return
}

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
// Mutate textStorage directly with proper attributes — skip CEUndoManager
// since auto-uppercase is automatic formatting, not a user edit.
let attrs = textView.typingAttributes
textView.textStorage.beginEditing()
textView.textStorage.replaceCharacters(
in: wordRange,
with: NSAttributedString(string: uppercased, attributes: attrs)
)
textView.textStorage.endEditing()
textView.needsDisplay = true
self.isUppercasing = false
}

return inSingleQuote || inDoubleQuote || inBacktick || inLineComment || inBlockComment
}

// MARK: - CodeEditSourceEditor Workarounds
Expand Down
Loading
Loading