diff --git a/TablePro/Core/Autocomplete/SQLKeywords.swift b/TablePro/Core/Autocomplete/SQLKeywords.swift index aef7b2119..0296df8e5 100644 --- a/TablePro/Core/Autocomplete/SQLKeywords.swift +++ b/TablePro/Core/Autocomplete/SQLKeywords.swift @@ -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) @@ -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" ] diff --git a/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift b/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift new file mode 100644 index 000000000..5f02f28a0 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift @@ -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)) + } +} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 2e6f67be1..5cb02b7c9 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -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 diff --git a/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift b/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift new file mode 100644 index 000000000..a146fae48 --- /dev/null +++ b/TableProTests/Views/Editor/KeywordUppercaseHelperTests.swift @@ -0,0 +1,323 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("KeywordUppercaseHelper") +struct KeywordUppercaseHelperTests { + + // MARK: - isWordBoundary + + @Test("Space is a word boundary") + func spaceIsBoundary() { + #expect(KeywordUppercaseHelper.isWordBoundary(" ")) + } + + @Test("Tab is a word boundary") + func tabIsBoundary() { + #expect(KeywordUppercaseHelper.isWordBoundary("\t")) + } + + @Test("Newline is a word boundary") + func newlineIsBoundary() { + #expect(KeywordUppercaseHelper.isWordBoundary("\n")) + } + + @Test("Parentheses are word boundaries") + func parensBoundary() { + #expect(KeywordUppercaseHelper.isWordBoundary("(")) + #expect(KeywordUppercaseHelper.isWordBoundary(")")) + } + + @Test("Comma and semicolon are word boundaries") + func commaSemicolonBoundary() { + #expect(KeywordUppercaseHelper.isWordBoundary(",")) + #expect(KeywordUppercaseHelper.isWordBoundary(";")) + } + + @Test("Letters are not word boundaries") + func lettersNotBoundary() { + #expect(!KeywordUppercaseHelper.isWordBoundary("a")) + #expect(!KeywordUppercaseHelper.isWordBoundary("Z")) + } + + @Test("Multi-character strings are not word boundaries") + func multiCharNotBoundary() { + #expect(!KeywordUppercaseHelper.isWordBoundary(" ")) + #expect(!KeywordUppercaseHelper.isWordBoundary("ab")) + } + + @Test("Empty string is not a word boundary") + func emptyNotBoundary() { + #expect(!KeywordUppercaseHelper.isWordBoundary("")) + } + + @Test("Digits are not word boundaries") + func digitsNotBoundary() { + #expect(!KeywordUppercaseHelper.isWordBoundary("5")) + } + + // MARK: - isWordCharacter + + @Test("Lowercase letters are word characters") + func lowercaseWordChars() { + #expect(KeywordUppercaseHelper.isWordCharacter(0x61)) // a + #expect(KeywordUppercaseHelper.isWordCharacter(0x7A)) // z + } + + @Test("Uppercase letters are word characters") + func uppercaseWordChars() { + #expect(KeywordUppercaseHelper.isWordCharacter(0x41)) // A + #expect(KeywordUppercaseHelper.isWordCharacter(0x5A)) // Z + } + + @Test("Digits are word characters") + func digitsWordChars() { + #expect(KeywordUppercaseHelper.isWordCharacter(0x30)) // 0 + #expect(KeywordUppercaseHelper.isWordCharacter(0x39)) // 9 + } + + @Test("Underscore is a word character") + func underscoreWordChar() { + #expect(KeywordUppercaseHelper.isWordCharacter(0x5F)) // _ + } + + @Test("Special chars are not word characters") + func specialNotWordChars() { + #expect(!KeywordUppercaseHelper.isWordCharacter(0x20)) // space + #expect(!KeywordUppercaseHelper.isWordCharacter(0x2D)) // - + #expect(!KeywordUppercaseHelper.isWordCharacter(0x2E)) // . + #expect(!KeywordUppercaseHelper.isWordCharacter(0x40)) // @ + } + + // MARK: - isInsideProtectedContext: String Literals + + @Test("Inside single-quoted string") + func insideSingleQuote() { + let text: NSString = "SELECT 'hello select " + // Position 20 is after "select " inside the string + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 20)) + } + + @Test("Outside single-quoted string") + func outsideSingleQuote() { + let text: NSString = "SELECT 'hello' select " + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 22)) + } + + @Test("Inside double-quoted identifier") + func insideDoubleQuote() { + let text: NSString = "SELECT \"select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 14)) + } + + @Test("Inside backtick identifier") + func insideBacktick() { + let text: NSString = "SELECT `select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 14)) + } + + @Test("Backslash-escaped quote does not end string") + func backslashEscapedQuote() { + let text: NSString = "SELECT 'it\\'s select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 20)) + } + + // MARK: - isInsideProtectedContext: Comments + + @Test("Inside line comment (--)") + func insideLineComment() { + let text: NSString = "SELECT -- select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 16)) + } + + @Test("After line comment on new line") + func afterLineCommentNewLine() { + let text: NSString = "-- comment\nselect" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 17)) + } + + @Test("Inside block comment") + func insideBlockComment() { + let text: NSString = "SELECT /* select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 16)) + } + + @Test("After closed block comment") + func afterClosedBlockComment() { + let text: NSString = "/* comment */ select" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 19)) + } + + @Test("Inside MySQL hash comment (#)") + func insideMySQLHashComment() { + let text: NSString = "SELECT # select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 15)) + } + + @Test("Hash inside string is not a comment") + func hashInsideStringNotComment() { + let text: NSString = "SELECT 'test#' select" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 21)) + } + + // MARK: - isInsideProtectedContext: Dollar-Quoting (PostgreSQL) + + @Test("Inside dollar-quoted string") + func insideDollarQuote() { + let text: NSString = "SELECT $$ select" + #expect(KeywordUppercaseHelper.isInsideProtectedContext(text, at: 16)) + } + + @Test("After closed dollar-quoted string") + func afterClosedDollarQuote() { + let text: NSString = "$$ body $$ select" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 17)) + } + + @Test("Single dollar is not a quote") + func singleDollarNotQuote() { + let text: NSString = "SELECT $5 select" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 16)) + } + + // MARK: - isInsideProtectedContext: Edge Cases + + @Test("Position 0 is never protected") + func positionZeroNotProtected() { + let text: NSString = "select" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 0)) + } + + @Test("Empty string at position 0") + func emptyStringNotProtected() { + let text: NSString = "" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 0)) + } + + @Test("Nested quotes: double inside single are ignored") + func nestedDoubleInSingle() { + let text: NSString = "SELECT '\"hello\"' select" + #expect(!KeywordUppercaseHelper.isInsideProtectedContext(text, at: 23)) + } + + // MARK: - keywordBeforePosition + + @Test("Detects lowercase keyword") + func detectsLowercaseKeyword() { + let text: NSString = "select " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 6) + #expect(result != nil) + #expect(result?.word == "select") + #expect(result?.range == NSRange(location: 0, length: 6)) + } + + @Test("Returns nil for already uppercase keyword") + func alreadyUppercaseReturnsNil() { + let text: NSString = "SELECT " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 6) + #expect(result == nil) + } + + @Test("Detects mixed-case keyword") + func detectsMixedCase() { + let text: NSString = "Select " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 6) + #expect(result != nil) + #expect(result?.word == "Select") + } + + @Test("Returns nil for non-keyword") + func nonKeywordReturnsNil() { + let text: NSString = "foobar " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 6) + #expect(result == nil) + } + + @Test("Returns nil for keyword inside string literal") + func keywordInsideStringReturnsNil() { + let text: NSString = "'select" + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 7) + #expect(result == nil) + } + + @Test("Returns nil for keyword inside comment") + func keywordInsideCommentReturnsNil() { + let text: NSString = "-- select" + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 9) + #expect(result == nil) + } + + @Test("Returns nil for keyword inside dollar-quote") + func keywordInsideDollarQuoteReturnsNil() { + let text: NSString = "$$ select" + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 9) + #expect(result == nil) + } + + @Test("Returns nil for keyword inside hash comment") + func keywordInsideHashCommentReturnsNil() { + let text: NSString = "# select" + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 8) + #expect(result == nil) + } + + @Test("Detects keyword after other text") + func keywordAfterOtherText() { + let text: NSString = "SELECT * from " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 13) + #expect(result != nil) + #expect(result?.word == "from") + #expect(result?.range == NSRange(location: 9, length: 4)) + } + + @Test("Returns nil for partial identifier containing keyword") + func identifierContainingKeywordReturnsNil() { + let text: NSString = "select_count " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 12) + #expect(result == nil) + } + + @Test("Returns nil at position 0") + func positionZeroReturnsNil() { + let text: NSString = "select" + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 0) + #expect(result == nil) + } + + @Test("Detects keyword followed by parenthesis") + func keywordBeforeParen() { + let text: NSString = "count(" + // "count" is not a keyword, but "where" is + let text2: NSString = "where(" + let result2 = KeywordUppercaseHelper.keywordBeforePosition(text2, at: 5) + #expect(result2 != nil) + #expect(result2?.word == "where") + } + + @Test("Returns nil for empty word (consecutive spaces)") + func emptyWordReturnsNil() { + let text: NSString = "SELECT " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 8) + #expect(result == nil) + } + + @Test("Keyword with digits is not a keyword") + func keywordWithDigitsNotKeyword() { + let text: NSString = "select2 " + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: 7) + #expect(result == nil) + } + + @Test("All major SQL keywords detected") + func majorKeywordsDetected() { + let keywords = ["select", "from", "where", "insert", "update", "delete", "create", "alter", + "drop", "join", "inner", "left", "right", "on", "group", "order", "having", + "limit", "offset", "union", "exists", "between", "like", "in", "is", "null", + "not", "and", "or", "as", "set", "into", "values", "begin", "commit", "rollback"] + for kw in keywords { + let text = kw as NSString + let result = KeywordUppercaseHelper.keywordBeforePosition(text, at: text.length) + #expect(result != nil, "Expected '\(kw)' to be detected as a keyword") + } + } +}