diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3a7cbeb9..164e93c740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Query > Clear Query** and **Query > Clear Results**. - Result chooser in the status bar, naming the result on screen and offering Pin, Unpin, Close and Close Others. - A reason on a dimmed Run, Explain, Format or Favorite saying why it cannot run. +- **Keyword case** in Settings > Editor: completed keywords and functions follow the case you type. (#2833) ### Changed @@ -65,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Ctrl+Cmd+J` from the editor's reserved shortcuts, so it can be bound in Settings > Keyboard. - Result tab strip above the query results, and the "Query" heading above the editor. - Trash button that cleared the query and the results under one name. +- **Auto-uppercase keywords** in Settings > Editor, replaced by **Keyword case**. ### Fixed @@ -182,6 +184,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - An AWS profile backed by IAM Identity Center, or an assume-role chain rooted on one, failing to authenticate. - AWS SSO sign-in leaving the `aws` CLI unable to refresh its own token. - AWS SSO, STS and RDS unreachable in the China, GovCloud and secret partitions. +- MongoDB autocomplete inserting `$MATCH` and `DB`, which the server rejects. +- ClickHouse autocomplete offering 18 function names the server rejects, `TOSTRING` and `UNIQ` among them. +- Completion inserted beside a non-ASCII prefix instead of replacing it: `SELECT 名` became `SELECT 名名前`. +- Caret landing after the closing parenthesis when accepting a function in the filter panel's Raw SQL field. ### Security diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 5a7b02ec87..94b87f8f8f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -77,17 +77,17 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { "MATERIALIZED", "WITH" ], functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - "NOW", "TODAY", "YESTERDAY", + "count", "sum", "avg", "max", "min", + "concat", "substring", "left", "right", "length", "lower", "upper", + "trim", "ltrim", "rtrim", "replace", + "now", "today", "yesterday", "CAST", - "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", - "TOSTRING", "TOINT32", "FORMATDATETIME", - "IF", "MULTIIF", - "ARRAYMAP", "ARRAYJOIN", - "MATCH", "CURRENTDATABASE", "VERSION", - "QUANTILE", "TOPK" + "uniq", "uniqExact", "argMin", "argMax", "groupArray", + "toString", "toInt32", "formatDateTime", + "if", "multiIf", + "arrayMap", "arrayJoin", + "match", "currentDatabase", "version", + "quantile", "topK" ], dataTypes: [ "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", @@ -111,7 +111,9 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { paginationStyle: .limit, requiresBackslashEscaping: true, caseSensitivityStyle: .caseFoldFunction, - caseFoldFunction: "lowerUTF8" + caseFoldFunction: "lowerUTF8", + textCastTypeName: nil, + functionNamesAreCaseInsensitive: false ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift index 8666824df2..37bab70e73 100644 --- a/Plugins/TableProPluginKit/SQLDialectDescriptor.swift +++ b/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -93,6 +93,16 @@ public struct SQLDialectDescriptor: Sendable { // Authoring public let operators: [SQLOperatorDescriptor] + /// Whether the engine matches built-in function names case-insensitively. + /// + /// True for standard SQL, so completion may present and insert a function name in whatever + /// case the user is typing. ClickHouse is the exception: measured on 26.9.1.52, `toString`, + /// `uniq`, `multiIf`, `arrayJoin` and `topK` are all rejected as UNKNOWN_FUNCTION in any other + /// case, while a curated SQL-compatibility set (`COUNT`, `IF`, `NOW`, `CAST`, `CONCAT`, + /// `LOWER`, `SUBSTRING`) is accepted in either. No rule derived from the declared spelling can + /// tell those apart, which is why the dialect has to say. + public let functionNamesAreCaseInsensitive: Bool + public enum CaseSensitivityStyle: String, Sendable { case ilikeOperator // PostgreSQL, CockroachDB, PGlite, DuckDB, Snowflake case caseFoldFunction // Oracle, BigQuery, ClickHouse, Redshift @@ -235,6 +245,7 @@ public struct SQLDialectDescriptor: Sendable { ) } + @_disfavoredOverload public init( identifierQuote: String, keywords: Set, @@ -252,6 +263,46 @@ public struct SQLDialectDescriptor: Sendable { caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction, operators: [SQLOperatorDescriptor] = [], textCastTypeName: String? + ) { + self.init( + identifierQuote: identifierQuote, + keywords: keywords, + functions: functions, + dataTypes: dataTypes, + tableOptions: tableOptions, + regexSyntax: regexSyntax, + booleanLiteralStyle: booleanLiteralStyle, + likeEscapeStyle: likeEscapeStyle, + paginationStyle: paginationStyle, + offsetFetchOrderBy: offsetFetchOrderBy, + requiresBackslashEscaping: requiresBackslashEscaping, + autoLimitStyle: autoLimitStyle, + caseSensitivityStyle: caseSensitivityStyle, + caseFoldFunction: caseFoldFunction, + operators: operators, + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: true + ) + } + + public init( + identifierQuote: String, + keywords: Set, + functions: Set, + dataTypes: Set, + tableOptions: [String] = [], + regexSyntax: RegexSyntax = .unsupported, + booleanLiteralStyle: BooleanLiteralStyle = .numeric, + likeEscapeStyle: LikeEscapeStyle = .explicit, + paginationStyle: PaginationStyle = .limit, + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)", + requiresBackslashEscaping: Bool = false, + autoLimitStyle: AutoLimitStyle = .limit, + caseSensitivityStyle: CaseSensitivityStyle = .unsupported, + caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction, + operators: [SQLOperatorDescriptor] = [], + textCastTypeName: String?, + functionNamesAreCaseInsensitive: Bool ) { self.identifierQuote = identifierQuote self.keywords = keywords @@ -269,6 +320,7 @@ public struct SQLDialectDescriptor: Sendable { self.caseFoldFunction = caseFoldFunction self.operators = operators self.textCastTypeName = textCastTypeName + self.functionNamesAreCaseInsensitive = functionNamesAreCaseInsensitive } public static let defaultCaseFoldFunction = "LOWER" @@ -293,7 +345,8 @@ public struct SQLDialectDescriptor: Sendable { caseSensitivityStyle: style, caseFoldFunction: caseFoldFunction, operators: operators, - textCastTypeName: textCastTypeName + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive ) } } diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index 47ed698ced..0c7ed56a66 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -70,6 +70,24 @@ final class CompletionEngine { provider.allFavoriteItems() } + /// Filters, ranks and cases an open session's candidates for `prefix`. + /// + /// The engine is the single gate every SQL completion item passes through on its way to a UI + /// surface, so the case policy is applied here rather than at each surface. `prefix` arrives in + /// the case the user typed it; the matcher lowercases internally. + func rank( + _ items: [SQLCompletionItem], + prefix: String, + context: SQLContext, + keywordCase: SQLKeywordCase + ) -> [SQLCompletionItem] { + SQLCompletionCasing.applied( + to: provider.filterRankAndLimit(items, prefix: prefix, context: context), + typedPrefix: prefix, + policy: keywordCase + ) + } + /// Completions for a single-table filter expression (a bare WHERE-clause /// fragment such as `id = 1 AND na`). The fragment is completed as the WHERE /// clause it denotes and columns are scoped to `tableName`, so suggestions @@ -77,7 +95,8 @@ final class CompletionEngine { func filterCompletions( fragment: String, cursorPosition: Int, - tableName: String + tableName: String, + keywordCase: SQLKeywordCase = .default ) async -> CompletionContext? { let clausePrefix = "WHERE " let prefixLength = (clausePrefix as NSString).length @@ -87,6 +106,7 @@ final class CompletionEngine { guard let context = await getCompletions( text: analysisText, cursorPosition: cursorPosition + prefixLength, + keywordCase: keywordCase, forcedTableReferences: references ) else { return nil @@ -109,6 +129,7 @@ final class CompletionEngine { func getCompletions( text: String, cursorPosition: Int, + keywordCase: SQLKeywordCase = .default, forcedTableReferences: [TableReference]? = nil ) async -> CompletionContext? { let nsText = text as NSString @@ -167,7 +188,7 @@ final class CompletionEngine { ) return CompletionContext( - items: items, + items: SQLCompletionCasing.applied(to: items, typedPrefix: context.prefix, policy: keywordCase), candidates: candidates, replacementRange: replacementRange, sqlContext: adjustedContext diff --git a/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift b/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift index 1d9112bad0..d9e59494e4 100644 --- a/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift +++ b/TablePro/Core/Autocomplete/Mongo/MongoCompletionService.swift @@ -22,7 +22,9 @@ final class MongoCompletionService: QueryCompletionService { var triggerCharacters: Set { [".", "$", "{", "[", "\"", "'", ":", " "] } func seedItems() -> [SQLCompletionItem] { - MongoVocabulary.shellCommands.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail) } + MongoVocabulary.shellCommands.map { + SQLCompletionItem.keyword($0.name, documentation: $0.detail, caseFolding: .fixed) + } } func prepare() async {} @@ -94,7 +96,9 @@ final class MongoCompletionService: QueryCompletionService { case .suppressed: return [] case .statementStart: - return seedItems() + favoriteItems() + [SQLCompletionItem.keyword("db", documentation: "Current database")] + return seedItems() + favoriteItems() + [ + SQLCompletionItem.keyword("db", documentation: "Current database", caseFolding: .fixed) + ] case .databaseMember: return await collectionItems() + methodItems(MongoVocabulary.databaseMethods) case .collectionMethod: @@ -126,24 +130,28 @@ final class MongoCompletionService: QueryCompletionService { } private func methodItems(_ methods: [(name: String, detail: String)]) -> [SQLCompletionItem] { - methods.map { SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail) } + methods.map { + SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail, caseFolding: .fixed) + } } private func operatorItems(_ operators: [(name: String, detail: String)]) -> [SQLCompletionItem] { - operators.map { SQLCompletionItem.operator($0.name, documentation: $0.detail) } + operators.map { SQLCompletionItem.operator($0.name, documentation: $0.detail, caseFolding: .fixed) } } private func stageItems(_ stages: [(name: String, detail: String)]) -> [SQLCompletionItem] { - stages.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail) } + stages.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail, caseFolding: .fixed) } } private func variableItems() -> [SQLCompletionItem] { - MongoVocabulary.systemVariables.map { SQLCompletionItem.keyword($0.name, documentation: $0.detail) } + MongoVocabulary.systemVariables.map { + SQLCompletionItem.keyword($0.name, documentation: $0.detail, caseFolding: .fixed) + } } private func constructorItems() -> [SQLCompletionItem] { MongoVocabulary.bsonConstructors.map { - SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail) + SQLCompletionItem.function($0.name, signature: "()", documentation: $0.detail, caseFolding: .fixed) } } diff --git a/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift b/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift index 56b599ee84..6da35b83a1 100644 --- a/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift @@ -8,6 +8,10 @@ import Foundation struct RawSQLFilterCompletionItem: Equatable { let label: String let insertText: String + /// Where the caret lands relative to the insertion start, in UTF-16 units. Resolved by + /// `SQLCompletionInsertion` so this field behaves exactly as the editor's popup does; the + /// filter field used to splice the raw text and park the caret past the closing parenthesis. + let cursorOffset: Int } struct RawSQLFilterCompletions { @@ -36,13 +40,19 @@ final class RawSQLFilterCompletionProvider { guard let context = await engine.filterCompletions( fragment: fieldText, cursorPosition: cursor, - tableName: tableName + tableName: tableName, + keywordCase: AppSettingsManager.shared.editor.keywordCase ) else { return nil } - let items = context.items.map { - RawSQLFilterCompletionItem(label: $0.label, insertText: $0.insertText) + let items = context.items.map { item in + let resolution = SQLCompletionInsertion.resolve(for: item) + return RawSQLFilterCompletionItem( + label: item.label, + insertText: resolution.text, + cursorOffset: resolution.cursorOffset + ) } guard !items.isEmpty else { return nil } diff --git a/TablePro/Core/Autocomplete/SQLCompletionCasing.swift b/TablePro/Core/Autocomplete/SQLCompletionCasing.swift new file mode 100644 index 0000000000..6d4b96df21 --- /dev/null +++ b/TablePro/Core/Autocomplete/SQLCompletionCasing.swift @@ -0,0 +1,111 @@ +// +// SQLCompletionCasing.swift +// TablePro +// +// Presentation case for completion items, decided from the typed prefix. +// + +import Foundation + +/// Whether a completion item's spelling may follow the typed prefix. +/// +/// `.fixed` is the default because most of what the popup offers is a name the server chose: +/// tables, columns, schemas, aliases, value literals, saved favorites, MongoDB pipeline stages and +/// plugin-supplied statement vocabulary. Re-casing any of those changes what the statement means. +/// Only vocabulary the engine matches case-insensitively opts in. +enum SQLCompletionCaseFolding { + case fixed + case caseInsensitive +} + +/// How keywords and built-in functions are cased when completed. +/// +/// The four values are psql's `COMP_KEYWORD_CASE`. The two `matchTyped` values differ only in the +/// case they fall back to when the prefix carries no cased character, which happens whenever the +/// popup is invoked on an empty token. +enum SQLKeywordCase: String, Codable, CaseIterable, Sendable { + case upper + case lower + case matchTypedElseUpper + case matchTypedElseLower + + static let `default` = SQLKeywordCase.matchTypedElseUpper + + var displayName: String { + switch self { + case .upper: return String(localized: "UPPERCASE") + case .lower: return String(localized: "lowercase") + case .matchTypedElseUpper: return String(localized: "Match what I type, otherwise UPPERCASE") + case .matchTypedElseLower: return String(localized: "Match what I type, otherwise lowercase") + } + } + + /// Whether typed text is rewritten as the user types. Only the two absolute values do; a + /// `matchTyped` value describes what a completion inserts and never touches what was typed. + var rewritesTypedText: Bool { + switch self { + case .upper, .lower: return true + case .matchTypedElseUpper, .matchTypedElseLower: return false + } + } + + /// The case `Format SQL` and the as-you-type rewriter apply, ignoring any typed prefix. + var prefersUppercase: Bool { + switch self { + case .upper, .matchTypedElseUpper: return true + case .lower, .matchTypedElseLower: return false + } + } +} + +enum SQLCompletionCasing { + /// The case a completion takes for `typedPrefix` under `policy`. + /// + /// Decided from the first *cased* scalar of the prefix and nothing else, which is psql's rule + /// (`pg_strdup_keyword_case` branches on `islower(ref[0])` alone). A leading capital therefore + /// reads as uppercase intent rather than as a Capitalised arm, matching Vim's `infercase` and + /// Emacs' `dabbrev`, and a mixed prefix like `sElE` follows its first letter rather than trying + /// to reproduce itself. + static func resolvedCase(typedPrefix: String, policy: SQLKeywordCase) -> Bool { + switch policy { + case .upper: return true + case .lower: return false + case .matchTypedElseUpper, .matchTypedElseLower: + guard let scalar = typedPrefix.unicodeScalars.first(where: { $0.properties.isCased }) else { + return policy.prefersUppercase + } + return !scalar.properties.isLowercase + } + } + + /// `text` folded to the resolved case as one phrase, so a multi-word keyword follows its first + /// letter throughout and never comes back as `group BY`. + /// + /// `String.uppercased()` and `String.lowercased()` are the locale-independent pair. The + /// `localized` and `NSString.lowercased(with:)` forms are not: under `tr_TR` they fold `INSERT` + /// to `ınsert`, which no SQL engine knows. `String.capitalized` is wrong for a different + /// reason: it lowercases the rest of every word, so it would destroy a candidate's own casing. + static func folded(_ text: String, uppercase: Bool) -> String { + uppercase ? text.uppercased() : text.lowercased() + } + + /// `items` with every `.caseInsensitive` entry re-cased for `typedPrefix`. + /// + /// `label` and `insertText` are rewritten together, or the popup would offer one spelling and + /// commit another. `filterText` is deliberately left alone: it is the canonical lowercase form + /// every matcher compares against, and `matchedRanges` are offsets into it. + static func applied( + to items: [SQLCompletionItem], + typedPrefix: String, + policy: SQLKeywordCase + ) -> [SQLCompletionItem] { + let uppercase = resolvedCase(typedPrefix: typedPrefix, policy: policy) + return items.map { item in + guard item.caseFolding == .caseInsensitive else { return item } + return item.recased( + label: folded(item.label, uppercase: uppercase), + insertText: folded(item.insertText, uppercase: uppercase) + ) + } + } +} diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 39e7030e3a..2a50f3089e 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -76,6 +76,11 @@ struct SQLCompletionItem: Identifiable, Hashable { let documentation: String? // Tooltip/description var sortPriority: Int // For ranking (lower = higher priority) let filterText: String // Text used for matching + /// Whether `label` and `insertText` may follow the typed prefix. Defaults to `.fixed`, so a + /// vocabulary opts in rather than having to remember to opt out: the kinds carry more than + /// language keywords, including value literals and `table.*`, and re-casing those is a + /// correctness bug rather than a style choice. + let caseFolding: SQLCompletionCaseFolding var matchedRanges: [Range] = [] var fuzzyPenalty: Int = 0 @@ -86,7 +91,8 @@ struct SQLCompletionItem: Identifiable, Hashable { detail: String? = nil, documentation: String? = nil, sortPriority: Int? = nil, - filterText: String? = nil + filterText: String? = nil, + caseFolding: SQLCompletionCaseFolding = .fixed ) { self.id = UUID() self.label = label @@ -96,6 +102,27 @@ struct SQLCompletionItem: Identifiable, Hashable { self.documentation = documentation self.sortPriority = sortPriority ?? kind.basePriority self.filterText = filterText ?? label.lowercased() + self.caseFolding = caseFolding + } + + private init(recasing item: SQLCompletionItem, label: String, insertText: String) { + self.id = item.id + self.label = label + self.kind = item.kind + self.insertText = insertText + self.detail = item.detail + self.documentation = item.documentation + self.sortPriority = item.sortPriority + self.filterText = item.filterText + self.caseFolding = item.caseFolding + self.matchedRanges = item.matchedRanges + self.fuzzyPenalty = item.fuzzyPenalty + } + + /// The same suggestion, spelled differently. `filterText` and `matchedRanges` are carried over + /// untouched because they describe the canonical lowercase form the matcher works in. + func recased(label: String, insertText: String) -> SQLCompletionItem { + SQLCompletionItem(recasing: self, label: label, insertText: insertText) } // MARK: - Hashable @@ -190,14 +217,24 @@ extension SQLCompletionItem { "ON DUPLICATE KEY UPDATE": "Handle duplicate key on insert (MySQL)", ] - /// Create a keyword completion item - static func keyword(_ keyword: String, documentation: String? = nil) -> SQLCompletionItem { + /// Create a keyword completion item. + /// + /// The canonical spelling is whatever the caller passes, which every SQL caller writes in + /// uppercase. It is no longer uppercased here, because the case a keyword is presented and + /// inserted in belongs to `SQLCompletionCasing` and the typed prefix, neither of which exists + /// at construction. + static func keyword( + _ keyword: String, + documentation: String? = nil, + caseFolding: SQLCompletionCaseFolding = .caseInsensitive + ) -> SQLCompletionItem { let doc = documentation ?? keywordDocs[keyword.uppercased()] return SQLCompletionItem( - label: keyword.uppercased(), + label: keyword, kind: .keyword, - insertText: keyword.uppercased(), - documentation: doc + insertText: keyword, + documentation: doc, + caseFolding: caseFolding ) } @@ -264,24 +301,35 @@ extension SQLCompletionItem { } /// Create a function completion item - static func function(_ name: String, signature: String? = nil, documentation: String? = nil) -> SQLCompletionItem { + static func function( + _ name: String, + signature: String? = nil, + documentation: String? = nil, + caseFolding: SQLCompletionCaseFolding = .caseInsensitive + ) -> SQLCompletionItem { let insertText = signature != nil ? "\(name)()" : name return SQLCompletionItem( label: name, kind: .function, insertText: insertText, detail: signature, - documentation: documentation + documentation: documentation, + caseFolding: caseFolding ) } /// Create an operator completion item - static func `operator`(_ op: String, documentation: String? = nil) -> SQLCompletionItem { + static func `operator`( + _ op: String, + documentation: String? = nil, + caseFolding: SQLCompletionCaseFolding = .caseInsensitive + ) -> SQLCompletionItem { SQLCompletionItem( label: op, kind: .operator, insertText: op, - documentation: documentation + documentation: documentation, + caseFolding: caseFolding ) } diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 77dd7d37f3..6695a427c1 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -144,9 +144,11 @@ final class SQLCompletionProvider { if let cachedFunctionItems { return cachedFunctionItems } var items = SQLKeywords.functionItems() if let dialect = cachedDialect, !dialect.functions.isEmpty { + let folding: SQLCompletionCaseFolding = + dialect.functionNamesAreCaseInsensitive ? .caseInsensitive : .fixed var seen = Set(items.map { $0.label.uppercased() }) for name in dialect.functions.sorted() where seen.insert(name.uppercased()).inserted { - items.append(SQLCompletionItem.function(name, signature: "\(name)(…)")) + items.append(SQLCompletionItem.function(name, signature: "\(name)(…)", caseFolding: folding)) } } cachedFunctionItems = items @@ -522,6 +524,10 @@ final class SQLCompletionProvider { } /// Operators the connected dialect declares, with their documented meaning. + /// + /// Case-insensitive like any other keyword, because the list is not only symbols: PostgreSQL + /// declares `IS DISTINCT FROM`, `IS NOT NULL` and `BETWEEN SYMMETRIC` here, and the same words + /// arrive from `SQLKeywords` as well. A symbol has no cased character, so folding leaves it be. private func dialectOperatorItems() -> [SQLCompletionItem] { guard let descriptor = cachedDialect else { return [] } return descriptor.operators.map { operatorDescriptor in @@ -532,12 +538,16 @@ final class SQLCompletionProvider { detail: operatorDescriptor.appliesToTypes.isEmpty ? nil : operatorDescriptor.appliesToTypes.joined(separator: ", "), - documentation: operatorDescriptor.summary + documentation: operatorDescriptor.summary, + caseFolding: .caseInsensitive ) } } /// Type names offered directly after a `::` cast operator, in the spelling users write. + /// + /// Lower case and fixed, which is the convention for a cast and is not the one `CREATE TABLE` + /// uses. The two spellings of one vocabulary are why this stays out of the keyword case policy. private func castTargetCompletionItems() -> [SQLCompletionItem] { guard let descriptor = cachedDialect, !descriptor.dataTypes.isEmpty else { return [] } return descriptor.dataTypes.sorted().map { typeName in @@ -554,7 +564,12 @@ final class SQLCompletionProvider { private func dataTypeKeywords() -> [SQLCompletionItem] { if let descriptor = cachedDialect, !descriptor.dataTypes.isEmpty { return descriptor.dataTypes.sorted().map { typeName in - var item = SQLCompletionItem(label: typeName, kind: .keyword, insertText: typeName) + var item = SQLCompletionItem( + label: typeName, + kind: .keyword, + insertText: typeName, + caseFolding: .caseInsensitive + ) item.sortPriority = 380 return item } diff --git a/TablePro/Core/Autocomplete/SQLCompletionService.swift b/TablePro/Core/Autocomplete/SQLCompletionService.swift index 85b121077b..18d9ab1657 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -28,6 +28,10 @@ final class SQLCompletionService: QueryCompletionService { var triggerCharacters: Set { [".", " ", ":", "(", ","] } + /// Read per request rather than captured, so changing the setting applies to the next + /// keystroke instead of the next connection. + private var keywordCase: SQLKeywordCase { AppSettingsManager.shared.editor.keywordCase } + /// Seeding starts a session the analyzer has not seen, so the context a previous session /// left behind stops describing anything. Ranking a seeded session against it would score /// the new prefix under the old clause. @@ -50,7 +54,7 @@ final class SQLCompletionService: QueryCompletionService { } func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { - engine.provider.filterRankAndLimit(items, prefix: prefix, context: lastContext) + engine.rank(items, prefix: prefix, context: lastContext, keywordCase: keywordCase) } func completions(in text: NSString, at offset: Int, isManualTrigger: Bool) async -> QueryCompletionSession? { @@ -60,7 +64,11 @@ final class SQLCompletionService: QueryCompletionService { let windowEnd = min(text.length, offset + Self.windowRadius) let window = text.substring(with: NSRange(location: windowStart, length: windowEnd - windowStart)) - guard let context = await engine.getCompletions(text: window, cursorPosition: offset - windowStart) else { + guard let context = await engine.getCompletions( + text: window, + cursorPosition: offset - windowStart, + keywordCase: keywordCase + ) else { return nil } guard !isSuppressedEmptyPrefix(context.sqlContext, isManualTrigger: isManualTrigger) else { return nil } diff --git a/TablePro/Core/Autocomplete/SQLTokenBoundary.swift b/TablePro/Core/Autocomplete/SQLTokenBoundary.swift index 998f666d4f..11623985ba 100644 --- a/TablePro/Core/Autocomplete/SQLTokenBoundary.swift +++ b/TablePro/Core/Autocomplete/SQLTokenBoundary.swift @@ -15,6 +15,18 @@ enum SQLTokenBoundary { private static let doubleQuote = UInt16(UnicodeScalar("\"").value) private static let underscore = UInt16(UnicodeScalar("_").value) + /// Scalars outside ASCII that an identifier may contain. Every engine TablePro speaks accepts + /// letters beyond ASCII in an identifier, quoted or (on MySQL, PostgreSQL and SQLite) bare, so + /// an ASCII-only rule read `SELECT 名` as an empty token and accepting a suggestion inserted + /// beside the typed text rather than replacing it. `$` is deliberately absent: it opens a + /// MongoDB pipeline stage, whose own analyzer relies on the token starting there. + private static let nonASCIIIdentifierScalars: CharacterSet = { + var set = CharacterSet.letters + set.formUnion(.decimalDigits) + set.formUnion(.nonBaseCharacters) + return set + }() + static func isIdentifierChar(_ ch: UInt16) -> Bool { if (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) { return true } if ch >= 0x30 && ch <= 0x39 { return true } @@ -28,18 +40,33 @@ enum SQLTokenBoundary { /// Start of the identifier segment ending at `cursor`, scanning backward /// over identifier and quote characters and stopping at a dot, so a /// qualified name like `schema.tab` resolves to the segment after the dot. + /// + /// The walk steps by composed character sequence rather than by UTF-16 unit, so a surrogate + /// pair and a base character with its combining marks are each tested and consumed whole. + /// ASCII input takes the single-unit path and behaves exactly as it did. static func segmentStart(in text: NSString, endingAt cursor: Int) -> Int { let clamped = min(max(cursor, 0), text.length) var start = clamped - var index = clamped - 1 - while index >= 0 { - guard isTokenChar(text.character(at: index)) else { break } - start = index - index -= 1 + while start > 0 { + let unit = text.character(at: start - 1) + if unit < 0x80 { + guard isTokenChar(unit) else { break } + start -= 1 + continue + } + let sequence = text.rangeOfComposedCharacterSequence(at: start - 1) + guard sequence.location + sequence.length <= start, + isNonASCIIIdentifierSequence(text.substring(with: sequence)) else { break } + start = sequence.location } return start } + private static func isNonASCIIIdentifierSequence(_ sequence: String) -> Bool { + guard !sequence.isEmpty else { return false } + return sequence.unicodeScalars.allSatisfy { nonASCIIIdentifierScalars.contains($0) } + } + /// Replacement range for an accepted completion: the live segment under /// the cursor when a cursor is available, otherwise the stored range /// computed when the suggestion window opened. diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift index d9f38954c2..5b79ec226e 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift @@ -48,17 +48,17 @@ extension PluginMetadataRegistry { "MATERIALIZED", "WITH" ], functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - "NOW", "TODAY", "YESTERDAY", + "count", "sum", "avg", "max", "min", + "concat", "substring", "left", "right", "length", "lower", "upper", + "trim", "ltrim", "rtrim", "replace", + "now", "today", "yesterday", "CAST", - "UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY", - "TOSTRING", "TOINT32", "FORMATDATETIME", - "IF", "MULTIIF", - "ARRAYMAP", "ARRAYJOIN", - "MATCH", "CURRENTDATABASE", "VERSION", - "QUANTILE", "TOPK" + "uniq", "uniqExact", "argMin", "argMax", "groupArray", + "toString", "toInt32", "formatDateTime", + "if", "multiIf", + "arrayMap", "arrayJoin", + "match", "currentDatabase", "version", + "quantile", "topK" ], dataTypes: [ "INT8", "INT16", "INT32", "INT64", "INT128", "INT256", @@ -82,7 +82,9 @@ extension PluginMetadataRegistry { paginationStyle: .limit, requiresBackslashEscaping: true, caseSensitivityStyle: .caseFoldFunction, - caseFoldFunction: "lowerUTF8" + caseFoldFunction: "lowerUTF8", + textCastTypeName: nil, + functionNamesAreCaseInsensitive: false ) let clickhouseColumnTypes: [String: [String]] = [ diff --git a/TablePro/Core/Services/Formatting/QueryFormatter.swift b/TablePro/Core/Services/Formatting/QueryFormatter.swift index f72874e001..4fc61f3716 100644 --- a/TablePro/Core/Services/Formatting/QueryFormatter.swift +++ b/TablePro/Core/Services/Formatting/QueryFormatter.swift @@ -12,14 +12,18 @@ protocol QueryFormatting { struct SQLQueryFormatter: QueryFormatting { private let dialect: DatabaseType + private let keywordCase: SQLKeywordCase private let formatter = SQLFormatterService() - init(dialect: DatabaseType) { + init(dialect: DatabaseType, keywordCase: SQLKeywordCase = .default) { self.dialect = dialect + self.keywordCase = keywordCase } func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult { - let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: .default) + var options = SQLFormatterOptions.default + options.keywordCase = keywordCase.prefersUppercase ? .upper : .lower + let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: options) return QueryFormatResult(text: result.formattedSQL, cursorOffset: result.cursorOffset) } } @@ -33,7 +37,7 @@ enum QueryFormatterFactory { case .javascript: return MongoShellFormatter() default: - return SQLQueryFormatter(dialect: dialect) + return SQLQueryFormatter(dialect: dialect, keywordCase: AppSettingsManager.shared.editor.keywordCase) } } } diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 3b454a9b87..444d8d13ec 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -336,7 +336,7 @@ internal struct SQLTokenFormatter { private mutating func handleKeyword(_ token: SQLToken, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { let upper = token.upperValue - let kw = options.uppercaseKeywords ? upper : token.value + let kw = options.keywordCase.applied(upper: upper, original: token.value) switch upper { case "SELECT": @@ -404,7 +404,7 @@ internal struct SQLTokenFormatter { case "TABLE", "AS": appendToken(kw) default: - if options.uppercaseKeywords && (functions.contains(upper) || dataTypes.contains(upper)) { + if options.keywordCase.rewritesKeywords, functions.contains(upper) || dataTypes.contains(upper) { appendToken(token.value) } else { appendToken(kw) @@ -466,7 +466,7 @@ internal struct SQLTokenFormatter { // LEFT OUTER JOIN → skip 2 tokens (OUTER, JOIN) if next?.upperValue == "OUTER" && next2?.upperValue == "JOIN" { inSelectColumns = false - let joinKw = options.uppercaseKeywords ? "\(upper) OUTER JOIN" : "\(upper.lowercased()) outer join" + let joinKw = options.keywordCase.synthesized("\(upper) OUTER JOIN") newline() appendToken(joinKw) replaceTop(with: .join) @@ -474,7 +474,7 @@ internal struct SQLTokenFormatter { // LEFT JOIN → skip 1 token (JOIN) } else if next?.upperValue == "JOIN" { inSelectColumns = false - let joinKw = options.uppercaseKeywords ? "\(upper) JOIN" : "\(upper.lowercased()) join" + let joinKw = options.keywordCase.synthesized("\(upper) JOIN") newline() appendToken(joinKw) replaceTop(with: .join) @@ -499,7 +499,7 @@ internal struct SQLTokenFormatter { // Inside window function parens — stay inline if clauseStack.contains(.windowParen) { if next?.upperValue == "BY" { - let byKw = options.uppercaseKeywords ? "BY" : "by" + let byKw = options.keywordCase.synthesized("BY") output += " " + kw + " " + byKw afterNewline = false skipCount = 1 @@ -510,7 +510,7 @@ internal struct SQLTokenFormatter { } inSelectColumns = false if next?.upperValue == "BY" { - let byKw = options.uppercaseKeywords ? "BY" : "by" + let byKw = options.keywordCase.synthesized("BY") newline() output += indentStr() + kw + " " + byKw afterNewline = false @@ -530,7 +530,7 @@ internal struct SQLTokenFormatter { var line = kw if upper == "UNION" && next?.upperValue == "ALL" { - let allKw = options.uppercaseKeywords ? "ALL" : "all" + let allKw = options.keywordCase.synthesized("ALL") line += " " + allKw skipCount = 1 // skip ALL } @@ -580,7 +580,7 @@ internal struct SQLTokenFormatter { private mutating func handleInsert(kw: String, next: SQLToken?) { if !isFirstClause { output += "\n" } if next?.upperValue == "INTO" { - let intoKw = options.uppercaseKeywords ? "INTO" : "into" + let intoKw = options.keywordCase.synthesized("INTO") output += kw + " " + intoKw skipCount = 1 // skip INTO } else { diff --git a/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift b/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift index b489babbc2..0bbfef3d2c 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift @@ -12,9 +12,41 @@ import Foundation // MARK: - Formatter Options +/// The case the formatter writes a keyword in. +/// +/// `preserve` keeps the spelling that is already in the statement wherever one exists; a keyword +/// the formatter synthesizes, such as the `BY` of a split `ORDER BY`, has no such spelling and is +/// written lower case. +enum SQLFormatterKeywordCase { + case upper + case lower + case preserve + + /// `upper` is the token's uppercased form, `original` the spelling the statement already had. + func applied(upper: String, original: String) -> String { + switch self { + case .upper: return upper + case .lower: return upper.lowercased() + case .preserve: return original + } + } + + /// A keyword the formatter writes from nothing, so there is no original spelling to preserve. + func synthesized(_ upper: String) -> String { + switch self { + case .upper: return upper + case .lower, .preserve: return upper.lowercased() + } + } + + /// Whether a token that is really a function or type name keeps the spelling it was written + /// with. Only `preserve` has nothing to decide, because it keeps every spelling anyway. + var rewritesKeywords: Bool { self != .preserve } +} + /// Configuration for SQL formatting behavior struct SQLFormatterOptions { - var uppercaseKeywords: Bool = true + var keywordCase: SQLFormatterKeywordCase = .upper var indentSize: Int = 2 var preserveComments: Bool = true diff --git a/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift b/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift index 7474759efa..1346dc990f 100644 --- a/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift +++ b/TablePro/Core/Utilities/SQL/KeywordUppercaseHelper.swift @@ -103,8 +103,12 @@ enum KeywordUppercaseHelper { } /// 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)? { + /// Returns nil if no word found, the word is not a SQL keyword, or it is already in `targetCase`. + static func keywordBeforePosition( + _ text: NSString, + at position: Int, + uppercase: Bool = true + ) -> (word: String, folded: String, range: NSRange)? { var wordStart = position while wordStart > 0 { let ch = text.character(at: wordStart - 1) @@ -119,9 +123,9 @@ enum KeywordUppercaseHelper { 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 } + let folded = uppercase ? word.uppercased() : word.lowercased() + guard folded != word else { return nil } - return (word: word, range: NSRange(location: wordStart, length: wordLength)) + return (word: word, folded: folded, range: NSRange(location: wordStart, length: wordLength)) } } diff --git a/TablePro/Models/Settings/EditorSettings.swift b/TablePro/Models/Settings/EditorSettings.swift index 9882147c0c..c92f98671d 100644 --- a/TablePro/Models/Settings/EditorSettings.swift +++ b/TablePro/Models/Settings/EditorSettings.swift @@ -73,7 +73,7 @@ struct EditorSettings: Codable, Equatable { var tabWidth: Int // 2, 4, or 8 spaces var wordWrap: Bool var vimModeEnabled: Bool - var uppercaseKeywords: Bool + var keywordCase: SQLKeywordCase var queryParametersEnabled: Bool var codeFoldingEnabled: Bool var highlightCurrentStatement: Bool @@ -87,7 +87,7 @@ struct EditorSettings: Codable, Equatable { tabWidth: 4, wordWrap: false, vimModeEnabled: false, - uppercaseKeywords: false, + keywordCase: .default, queryParametersEnabled: true, codeFoldingEnabled: true, highlightCurrentStatement: true, @@ -102,7 +102,7 @@ struct EditorSettings: Codable, Equatable { tabWidth: Int = 4, wordWrap: Bool = false, vimModeEnabled: Bool = false, - uppercaseKeywords: Bool = false, + keywordCase: SQLKeywordCase = .default, queryParametersEnabled: Bool = true, codeFoldingEnabled: Bool = true, highlightCurrentStatement: Bool = true, @@ -115,7 +115,7 @@ struct EditorSettings: Codable, Equatable { self.tabWidth = tabWidth self.wordWrap = wordWrap self.vimModeEnabled = vimModeEnabled - self.uppercaseKeywords = uppercaseKeywords + self.keywordCase = keywordCase self.queryParametersEnabled = queryParametersEnabled self.codeFoldingEnabled = codeFoldingEnabled self.highlightCurrentStatement = highlightCurrentStatement @@ -131,7 +131,7 @@ struct EditorSettings: Codable, Equatable { tabWidth = try container.decodeIfPresent(Int.self, forKey: .tabWidth) ?? 4 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 + keywordCase = try Self.decodeKeywordCase(from: container) queryParametersEnabled = try container.decodeIfPresent(Bool.self, forKey: .queryParametersEnabled) ?? true codeFoldingEnabled = try container.decodeIfPresent(Bool.self, forKey: .codeFoldingEnabled) ?? true highlightCurrentStatement = try container.decodeIfPresent(Bool.self, forKey: .highlightCurrentStatement) ?? true @@ -140,6 +140,57 @@ struct EditorSettings: Codable, Equatable { jsonViewerPreferredMode = try container.decodeIfPresent(JSONViewMode.self, forKey: .jsonViewerPreferredMode) ?? .text } + /// Spelled out rather than synthesized so `uppercaseKeywords` survives as a wire key after the + /// property it named became `keywordCase`. Editor settings sync as one JSON blob, so both the + /// key an older build reads and the key this one writes have to be in it. + private enum CodingKeys: String, CodingKey { + case showLineNumbers + case highlightCurrentLine + case tabWidth + case wordWrap + case vimModeEnabled + case keywordCase + case uppercaseKeywords + case queryParametersEnabled + case codeFoldingEnabled + case highlightCurrentStatement + case showStatementRunControls + case showInvisibleCharacters + case jsonViewerPreferredMode + } + + private static func decodeKeywordCase( + from container: KeyedDecodingContainer + ) throws -> SQLKeywordCase { + /// A raw value this build does not know falls back rather than throwing. Editor settings + /// decode as one blob, so a value written by a newer build would otherwise take every + /// other editor setting down with it. + if let stored = try? container.decodeIfPresent(SQLKeywordCase.self, forKey: .keywordCase) { + return stored + } + guard let legacy = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) else { + return .default + } + return legacy ? .upper : .matchTypedElseUpper + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(showLineNumbers, forKey: .showLineNumbers) + try container.encode(highlightCurrentLine, forKey: .highlightCurrentLine) + try container.encode(tabWidth, forKey: .tabWidth) + try container.encode(wordWrap, forKey: .wordWrap) + try container.encode(vimModeEnabled, forKey: .vimModeEnabled) + try container.encode(keywordCase, forKey: .keywordCase) + try container.encode(keywordCase == .upper, forKey: .uppercaseKeywords) + try container.encode(queryParametersEnabled, forKey: .queryParametersEnabled) + try container.encode(codeFoldingEnabled, forKey: .codeFoldingEnabled) + try container.encode(highlightCurrentStatement, forKey: .highlightCurrentStatement) + try container.encode(showStatementRunControls, forKey: .showStatementRunControls) + try container.encode(showInvisibleCharacters, forKey: .showInvisibleCharacters) + try container.encode(jsonViewerPreferredMode, forKey: .jsonViewerPreferredMode) + } + /// Clamped tab width (1-16) var clampedTabWidth: Int { min(max(tabWidth, 1), 16) diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index 836d1144d7..1baf86320e 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -159,7 +159,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let length = offset - start guard length > 0, length <= maximumPrefixLength else { return nil } - let prefix = text.substring(with: NSRange(location: start, length: length)).lowercased() + let prefix = text.substring(with: NSRange(location: start, length: length)) guard !prefix.isEmpty else { return nil } let ranked = service.rank(session.candidates, prefix: prefix) diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 73f56ffd77..09e14aeaa9 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -693,20 +693,30 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { lastInlineSourceKind = kind } - // MARK: - Keyword Auto-Uppercase + // MARK: - Keyword Case + /// Rewrites the keyword just completed by a word boundary to the configured case. + /// + /// Only the two absolute `SQLKeywordCase` values rewrite what the user typed. Under either + /// `matchTyped` value this does nothing, so an accepted lowercase completion is not flipped + /// back to uppercase by the next space. private func uppercaseKeywordIfNeeded(textView: TextView, range: NSRange, string: String) { + let keywordCase = AppSettingsManager.shared.editor.keywordCase guard !isUppercasing, - AppSettingsManager.shared.editor.uppercaseKeywords, + keywordCase.rewritesTypedText, KeywordUppercaseHelper.isWordBoundary(string), (textView.textStorage.string as NSString).length < 500_000 else { return } let nsText = textView.textStorage.string as NSString - guard let match = KeywordUppercaseHelper.keywordBeforePosition(nsText, at: range.location) else { return } + guard let match = KeywordUppercaseHelper.keywordBeforePosition( + nsText, + at: range.location, + uppercase: keywordCase.prefersUppercase + ) else { return } let word = match.word let wordRange = match.range - let uppercased = word.uppercased() + let uppercased = match.folded isUppercasing = true DispatchQueue.main.async { [weak self, weak textView] in diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index d6b9e7488d..9356303d61 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -60,10 +60,16 @@ struct FilterValueTextField: NSViewRepresentable { return !CharacterSet.whitespaces.contains(scalar) } - nonisolated static func splice(into current: String, range: NSRange, insertText: String) -> (text: String, caret: Int)? { + nonisolated static func splice( + into current: String, + range: NSRange, + insertText: String, + cursorOffset: Int? = nil + ) -> (text: String, caret: Int)? { let ns = current as NSString guard range.location >= 0, range.location + range.length <= ns.length else { return nil } - let caret = range.location + (insertText as NSString).length + let offset = cursorOffset ?? (insertText as NSString).length + let caret = range.location + min(max(offset, 0), (insertText as NSString).length) return (ns.replacingCharacters(in: range, with: insertText), caret) } @@ -376,7 +382,7 @@ struct FilterValueTextField: NSViewRepresentable { return } let items = result.items.map { - SuggestionItem(label: $0.label, insertText: $0.insertText) + SuggestionItem(label: $0.label, insertText: $0.insertText, cursorOffset: $0.cursorOffset) } self.presentSuggestions(items, for: textField, replacementRange: result.replacementRange) } @@ -463,7 +469,7 @@ struct FilterValueTextField: NSViewRepresentable { text.wrappedValue = item.insertText textField?.stringValue = item.insertText case .sqlTokens: - spliceTokenCompletion(item.insertText) + spliceTokenCompletion(item.insertText, cursorOffset: item.cursorOffset) } dismissSuggestions() if submitting { @@ -471,10 +477,13 @@ struct FilterValueTextField: NSViewRepresentable { } } - private func spliceTokenCompletion(_ insertText: String) { + private func spliceTokenCompletion(_ insertText: String, cursorOffset: Int) { guard let textField, let range = latestReplacementRange, let spliced = FilterValueTextField.splice( - into: textField.stringValue, range: range, insertText: insertText + into: textField.stringValue, + range: range, + insertText: insertText, + cursorOffset: cursorOffset ) else { return } @@ -529,6 +538,15 @@ struct FilterValueTextField: NSViewRepresentable { private struct SuggestionItem: Equatable { let label: String let insertText: String + /// Caret position relative to the insertion start, so a function completion parks the + /// caret between its parentheses here exactly as it does in the query editor. + let cursorOffset: Int + + init(label: String, insertText: String, cursorOffset: Int? = nil) { + self.label = label + self.insertText = insertText + self.cursorOffset = cursorOffset ?? (insertText as NSString).length + } } @MainActor diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index f59e3bba5f..cb3b69b5c0 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -23,7 +23,11 @@ struct EditorSettingsView: View { Text("4 spaces").tag(4) Text("8 spaces").tag(8) } - Toggle("Auto-uppercase keywords", isOn: $settings.uppercaseKeywords) + Picker("Keyword case:", selection: $settings.keywordCase) { + ForEach(SQLKeywordCase.allCases, id: \.self) { keywordCase in + Text(keywordCase.displayName).tag(keywordCase) + } + } Toggle("Query parameters (:name syntax)", isOn: $settings.queryParametersEnabled) Toggle("Vim mode", isOn: $settings.vimModeEnabled) .accessibilityIdentifier("vim-mode-toggle") diff --git a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift index e2a8026ff6..b27d4a3022 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineFilterTests.swift @@ -169,7 +169,7 @@ struct CompletionEngineFilterTests { tableName: "users" ) let labels = result?.items.map(\.label) ?? [] - #expect(labels.contains("LIKE")) + #expect(labels.contains { $0.caseInsensitiveCompare("LIKE") == .orderedSame }) } @Test("No completion inside a string literal") diff --git a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift index a6a1dd05e7..9c1773e1a6 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift @@ -200,4 +200,55 @@ struct CompletionEngineTests { #expect(labels.contains("region")) #expect(labels.contains("total")) } + + // MARK: - Keyword case + + @Test("An opening lowercase prefix opens the popup with lowercase keywords") + func lowercasePrefixOpensLowercase() async { + let result = await engine.getCompletions(text: "sel", cursorPosition: 3, keywordCase: .matchTypedElseUpper) + #expect(result?.items.contains { $0.label == "select" && $0.insertText == "select" } == true) + #expect(result?.items.contains { $0.label == "SELECT" } == false) + } + + @Test("An opening uppercase prefix opens the popup with uppercase keywords") + func uppercasePrefixOpensUppercase() async { + let result = await engine.getCompletions(text: "SEL", cursorPosition: 3, keywordCase: .matchTypedElseUpper) + #expect(result?.items.contains { $0.label == "SELECT" } == true) + } + + /// The candidate pool an open popup re-ranks against stays canonical, so a later prefix folds + /// from the vocabulary's own spelling rather than from whatever the previous keystroke produced. + @Test("The session's candidate pool is not re-cased") + func candidatePoolStaysCanonical() async { + let result = await engine.getCompletions(text: "sel", cursorPosition: 3, keywordCase: .matchTypedElseUpper) + #expect(result?.candidates.contains { $0.label == "SELECT" } == true) + } + + /// The per-keystroke path: the adapter hands `rank` the prefix in the case the user typed it, + /// so a prefix lowercased on the way in would silently lowercase every later keystroke. + @Test("Ranking cases from the prefix it is given") + func rankingFollowsTheGivenPrefix() async { + let result = await engine.getCompletions(text: "s", cursorPosition: 1, keywordCase: .matchTypedElseUpper) + let candidates = result?.candidates ?? [] + #expect(!candidates.isEmpty) + + let lowered = engine.rank( + candidates, prefix: "sel", context: .unanalyzed, keywordCase: .matchTypedElseUpper + ) + #expect(lowered.contains { $0.insertText == "select" }) + + let raised = engine.rank( + candidates, prefix: "SEL", context: .unanalyzed, keywordCase: .matchTypedElseUpper + ) + #expect(raised.contains { $0.insertText == "SELECT" }) + } + + @Test("The absolute policies ignore the typed prefix at both entry points") + func absolutePolicyIgnoresPrefix() async { + let result = await engine.getCompletions(text: "sel", cursorPosition: 3, keywordCase: .upper) + #expect(result?.items.contains { $0.insertText == "SELECT" } == true) + + let lowered = await engine.getCompletions(text: "SEL", cursorPosition: 3, keywordCase: .lower) + #expect(lowered?.items.contains { $0.insertText == "select" } == true) + } } diff --git a/TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift b/TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift new file mode 100644 index 0000000000..d9941a30df --- /dev/null +++ b/TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift @@ -0,0 +1,72 @@ +// +// MongoCompletionCaseTests.swift +// TableProTests +// +// MongoDB's vocabulary is JavaScript, so every name in it is case-significant. +// Completion used to route it through a factory that uppercased, which offered +// `$MATCH` and `DB` and produced pipelines the server rejects. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MongoDB completion case") +@MainActor +struct MongoCompletionCaseTests { + private func service() -> MongoCompletionService { + MongoCompletionService(schemaProvider: nil, databaseType: .mongodb) + } + + private func completions(_ text: String) async -> [SQLCompletionItem] { + let ns = text as NSString + let session = await service().completions(in: ns, at: ns.length, isManualTrigger: true) + return session?.items ?? [] + } + + @Test("A pipeline stage is offered and inserted in its own case") + func pipelineStageKeepsItsCase() async { + let items = await completions("db.orders.aggregate([{ $ma") + let match = items.first { $0.label.lowercased() == "$match" } + #expect(match?.label == "$match") + #expect(match?.insertText == "$match") + #expect(!items.contains { $0.insertText == "$MATCH" }) + } + + @Test("An uppercase prefix does not uppercase a stage") + func uppercasePrefixDoesNotRecaseStage() async { + let items = await completions("db.orders.aggregate([{ $MA") + #expect(!items.contains { $0.insertText == "$MATCH" }) + } + + @Test("A camel-cased stage keeps its inner capitals") + func camelCaseStageIsIntact() async { + let items = await completions("db.orders.aggregate([{ $addf") + #expect(items.contains { $0.insertText == "$addFields" }) + } + + @Test("The db handle is offered in lower case") + func dbHandleKeepsItsCase() async { + let items = await completions("d") + #expect(items.contains { $0.insertText == "db" }) + #expect(!items.contains { $0.insertText == "DB" }) + } + + @Test("A collection method keeps its camel case") + func collectionMethodKeepsItsCase() async { + let items = await completions("db.orders.inserto") + #expect(items.contains { $0.insertText == "insertOne()" }) + } + + @Test("A query operator keeps its case") + func queryOperatorKeepsItsCase() async { + let items = await completions("db.orders.find({ status: { $g") + #expect(items.contains { $0.insertText == "$gte" }) + #expect(!items.contains { $0.insertText == "$GTE" }) + } + + @Test("Every item the Mongo service builds declares a fixed spelling") + func everyMongoItemIsFixed() { + #expect(service().seedItems().allSatisfy { $0.caseFolding == .fixed }) + } +} diff --git a/TableProTests/Core/Autocomplete/SQLCompletionCasingTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionCasingTests.swift new file mode 100644 index 0000000000..7c706d2c28 --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLCompletionCasingTests.swift @@ -0,0 +1,327 @@ +// +// SQLCompletionCasingTests.swift +// TableProTests +// +// Tests for SQLCompletionCasing: which completion items follow the typed case, +// how the case is read off the prefix, and which vocabularies are never touched. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQLCompletionCasing") +struct SQLCompletionCasingTests { + private func applied(_ items: [SQLCompletionItem], _ prefix: String, _ policy: SQLKeywordCase = .default) + -> [SQLCompletionItem] { + SQLCompletionCasing.applied(to: items, typedPrefix: prefix, policy: policy) + } + + private func first(_ keyword: String, prefix: String, policy: SQLKeywordCase = .default) -> SQLCompletionItem { + applied([SQLCompletionItem.keyword(keyword)], prefix, policy)[0] + } + + // MARK: - The reported behaviour + + @Test("A lowercase prefix completes to a lowercase keyword") + func lowercasePrefix() { + let item = first("SELECT", prefix: "sel") + #expect(item.label == "select") + #expect(item.insertText == "select") + } + + @Test("An uppercase prefix completes to an uppercase keyword") + func uppercasePrefix() { + let item = first("SELECT", prefix: "SEL") + #expect(item.label == "SELECT") + #expect(item.insertText == "SELECT") + } + + @Test("A lowercase prefix completes to a lowercase function, parentheses included") + func lowercaseFunction() { + let items = applied([SQLCompletionItem.function("COUNT", signature: "COUNT(expr)")], "cou") + #expect(items[0].label == "count") + #expect(items[0].insertText == "count()") + + let resolution = SQLCompletionInsertion.resolve(for: items[0]) + #expect(resolution.text == "count()") + #expect(resolution.cursorOffset == 6) + } + + @Test("An uppercase prefix completes to an uppercase function") + func uppercaseFunction() { + let items = applied([SQLCompletionItem.function("COUNT", signature: "COUNT(expr)")], "COU") + #expect(items[0].insertText == "COUNT()") + } + + // MARK: - How the case is read off the prefix + + @Test("Only the first cased character decides, so a leading capital means uppercase") + func leadingCapital() { + #expect(first("SELECT", prefix: "Sel").insertText == "SELECT") + } + + @Test("A mixed prefix follows its first cased character rather than reproducing itself") + func mixedPrefix() { + #expect(first("SELECT", prefix: "sElE").insertText == "select") + } + + @Test("A single character is enough to decide") + func singleCharacter() { + #expect(first("SELECT", prefix: "s").insertText == "select") + #expect(first("SELECT", prefix: "S").insertText == "SELECT") + } + + @Test("A prefix that leads with an uncased character uses the first cased one after it") + func leadingUncasedCharacter() { + #expect(first("SELECT", prefix: "_sel").insertText == "select") + #expect(first("SELECT", prefix: "1SEL").insertText == "SELECT") + } + + // MARK: - No cased character in the prefix + + @Test("An empty prefix takes the policy's fallback") + func emptyPrefix() { + #expect(first("SELECT", prefix: "", policy: .matchTypedElseUpper).insertText == "SELECT") + #expect(first("SELECT", prefix: "", policy: .matchTypedElseLower).insertText == "select") + } + + @Test("A prefix with no cased scalar takes the policy's fallback") + func uncasedPrefix() { + for prefix in ["_", "1", "__42", "名"] { + #expect(first("SELECT", prefix: prefix, policy: .matchTypedElseUpper).insertText == "SELECT") + #expect(first("SELECT", prefix: prefix, policy: .matchTypedElseLower).insertText == "select") + } + } + + // MARK: - The absolute policies ignore the prefix + + @Test("The absolute policies ignore what was typed") + func absolutePolicies() { + #expect(first("SELECT", prefix: "sel", policy: .upper).insertText == "SELECT") + #expect(first("SELECT", prefix: "SEL", policy: .lower).insertText == "select") + } + + // MARK: - Phrases + + @Test("A multi-word keyword is cased as one phrase") + func multiWordKeyword() { + #expect(first("GROUP BY", prefix: "group b").insertText == "group by") + #expect(first("LEFT OUTER JOIN", prefix: "left o").insertText == "left outer join") + #expect(first("IS NOT NULL", prefix: "IS N").insertText == "IS NOT NULL") + } + + // MARK: - Vocabularies that are never re-cased + + @Test("Identifiers keep the case the catalogue reported") + func identifiersAreNeverRecased() { + let items = [ + SQLCompletionItem.table("MY_TABLE"), + SQLCompletionItem.table("MyView", isView: true), + SQLCompletionItem.column("UserID", dataType: "int"), + SQLCompletionItem.schemaName("Public"), + SQLCompletionItem.databaseName("AnalyticsDB") + ] + for prefix in ["my", "MY", "My", ""] { + let cased = applied(items, prefix) + #expect(cased.map(\.label) == items.map(\.label)) + #expect(cased.map(\.insertText) == items.map(\.insertText)) + } + } + + @Test("A saved favorite keeps its keyword and its query") + func favoritesAreNeverRecased() { + let item = SQLCompletionItem.favorite(keyword: "slc", name: "Count", query: "SELECT COUNT(*) FROM Orders") + let cased = applied([item], "SLC")[0] + #expect(cased.label == "slc") + #expect(cased.insertText == "SELECT COUNT(*) FROM Orders") + } + + @Test("A value literal is data and keeps its case") + func valueLiteralsAreNeverRecased() { + var item = SQLCompletionItem( + label: "'Active'", + kind: .keyword, + insertText: "'Active'", + filterText: "active" + ) + item.sortPriority = 10 + #expect(applied([item], "act")[0].insertText == "'Active'") + #expect(applied([item], "ACT")[0].insertText == "'Active'") + } + + @Test("A qualified star carries an identifier and keeps its case") + func qualifiedStarIsNeverRecased() { + let item = SQLCompletionItem(label: "Users.*", kind: .keyword, insertText: "Users.*") + #expect(applied([item], "us")[0].insertText == "Users.*") + } + + @Test("A MongoDB pipeline stage keeps its camel case") + func mongoStagesAreNeverRecased() { + let items = [ + SQLCompletionItem.keyword("$match", caseFolding: .fixed), + SQLCompletionItem.keyword("$unwind", caseFolding: .fixed), + SQLCompletionItem.function("insertOne", signature: "()", caseFolding: .fixed), + SQLCompletionItem.operator("$gte", caseFolding: .fixed) + ] + for prefix in ["$MA", "$ma", "INSERT", ""] { + #expect(applied(items, prefix).map(\.insertText) == items.map(\.insertText)) + } + } + + @Test("A function a dialect declares case-sensitive keeps its spelling") + func caseSensitiveDialectFunction() { + let item = SQLCompletionItem.function("toString", signature: "toString(…)", caseFolding: .fixed) + #expect(applied([item], "tos")[0].insertText == "toString()") + #expect(applied([item], "TOS")[0].insertText == "toString()") + } + + // MARK: - Invariants of the transform itself + + @Test("The label and the inserted text never disagree") + func labelAndInsertTextAgree() { + let items = SQLKeywords.keywordItems() + SQLKeywords.functionItems() + for prefix in ["a", "A", "", "_"] { + for item in applied(items, prefix) where item.insertText.hasSuffix("()") { + #expect(item.insertText == item.label + "()") + } + } + } + + @Test("The matcher's canonical text and match ranges survive re-casing") + func matchingStateSurvives() { + var item = SQLCompletionItem.keyword("SELECT") + item.matchedRanges = [0..<3] + item.fuzzyPenalty = 7 + let cased = applied([item], "sel")[0] + #expect(cased.filterText == "select") + #expect(cased.matchedRanges == [0..<3]) + #expect(cased.fuzzyPenalty == 7) + #expect(cased.sortPriority == item.sortPriority) + #expect(cased.kind == item.kind) + } + + @Test("Re-casing an already re-cased item gives the same answer as re-casing the original") + func foldingIsIdempotent() { + let original = SQLCompletionItem.keyword("SELECT") + let lowered = applied([original], "sel")[0] + #expect(applied([lowered], "SEL")[0].insertText == "SELECT") + #expect(applied([lowered], "sel")[0].insertText == "select") + } + + /// A locale-sensitive fold turns `INSERT` into `ınsert` and `insert` into `İNSERT` under + /// Turkish, and no SQL engine knows either spelling. The first expectation pins that the hazard + /// is real rather than folklore; the rest pin that the transform does not take that path. + @Test("Folding is locale-independent") + func foldingIgnoresLocale() { + let turkish = Locale(identifier: "tr_TR") + #expect(("INSERT" as NSString).lowercased(with: turkish) == "ınsert") + #expect(("insert" as NSString).uppercased(with: turkish) == "İNSERT") + + for keyword in ["INSERT", "LIMIT", "DISTINCT", "ILIKE"] { + #expect(first(keyword, prefix: "a").insertText == keyword.lowercased()) + #expect(first(keyword, prefix: "A").insertText == keyword) + #expect(SQLCompletionCasing.folded(keyword, uppercase: false) == keyword.lowercased()) + #expect(SQLCompletionCasing.folded(keyword.lowercased(), uppercase: true) == keyword) + } + } + + // MARK: - The policy's own vocabulary + + @Test("Only the absolute policies rewrite what the user typed") + func rewritesTypedText() { + #expect(SQLKeywordCase.upper.rewritesTypedText) + #expect(SQLKeywordCase.lower.rewritesTypedText) + #expect(!SQLKeywordCase.matchTypedElseUpper.rewritesTypedText) + #expect(!SQLKeywordCase.matchTypedElseLower.rewritesTypedText) + } + + @Test("Every policy names the case it falls back to") + func prefersUppercase() { + #expect(SQLKeywordCase.upper.prefersUppercase) + #expect(SQLKeywordCase.matchTypedElseUpper.prefersUppercase) + #expect(!SQLKeywordCase.lower.prefersUppercase) + #expect(!SQLKeywordCase.matchTypedElseLower.prefersUppercase) + } + + @Test("Every policy has a distinct, non-empty display name") + func displayNames() { + let names = SQLKeywordCase.allCases.map(\.displayName) + #expect(Set(names).count == SQLKeywordCase.allCases.count) + #expect(names.allSatisfy { !$0.isEmpty }) + } + + @Test("The default reproduces the shipped output when nothing has been typed") + func defaultPolicy() { + #expect(SQLKeywordCase.default == .matchTypedElseUpper) + #expect(first("SELECT", prefix: "").insertText == "SELECT") + } + + // MARK: - Through the engine, with a dialect + + private func dialect( + functions: Set, + functionNamesAreCaseInsensitive: Bool, + operators: [SQLOperatorDescriptor] = [] + ) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: ["SELECT", "FROM", "WHERE"], + functions: functions, + dataTypes: [], + operators: operators, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive + ) + } + + private func inserted(_ text: String, dialect: SQLDialectDescriptor) async -> [String] { + let engine = CompletionEngine(schemaProvider: nil, databaseType: nil, dialect: dialect) + let context = await engine.getCompletions( + text: text, + cursorPosition: (text as NSString).length, + keywordCase: .matchTypedElseUpper + ) + return context?.items.map(\.insertText) ?? [] + } + + @Test("A dialect whose function names are case-insensitive follows the typed case") + func caseInsensitiveDialectFunctionsFollowTheTypedCase() async { + let descriptor = dialect(functions: ["STRING_AGG"], functionNamesAreCaseInsensitive: true) + #expect(await inserted("SELECT string_a", dialect: descriptor).contains("string_agg()")) + #expect(await inserted("SELECT STRING_A", dialect: descriptor).contains("STRING_AGG()")) + } + + /// Measured on ClickHouse 26.9.1.52: `toString` and `uniq` are rejected as UNKNOWN_FUNCTION in + /// any other case, so the dialect declares its function names case-sensitive and they are never + /// folded, whichever case the prefix is in. + @Test("A dialect whose function names are case-sensitive keeps every spelling") + func caseSensitiveDialectFunctionsKeepTheirSpelling() async { + let descriptor = dialect(functions: ["toString", "uniq"], functionNamesAreCaseInsensitive: false) + #expect(await inserted("SELECT tos", dialect: descriptor).contains("toString()")) + #expect(await inserted("SELECT TOS", dialect: descriptor).contains("toString()")) + #expect(await inserted("SELECT uni", dialect: descriptor).contains("uniq()")) + #expect(await inserted("SELECT UNI", dialect: descriptor).contains("uniq()")) + } + + /// A dialect's operator list is not only symbols: PostgreSQL declares `IS DISTINCT FROM` and + /// `IS NOT NULL` there, and the same words also arrive from the built-in keyword table. + @Test("A word operator a dialect declares follows the typed case") + func dialectWordOperatorsFollowTheTypedCase() async { + let descriptor = dialect( + functions: [], + functionNamesAreCaseInsensitive: true, + operators: [ + SQLOperatorDescriptor(symbol: "IS DISTINCT FROM", summary: "Not equal", category: .predicate), + SQLOperatorDescriptor(symbol: "@>", summary: "Contains", category: .json) + ] + ) + let lowered = await inserted("SELECT * FROM t WHERE a is d", dialect: descriptor) + #expect(lowered.contains("is distinct from")) + #expect(!lowered.contains("IS DISTINCT FROM")) + + let raised = await inserted("SELECT * FROM t WHERE a IS D", dialect: descriptor) + #expect(raised.contains("IS DISTINCT FROM")) + } +} diff --git a/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift b/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift index d9b8f3ce32..ea3bfcf615 100644 --- a/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift +++ b/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift @@ -96,4 +96,75 @@ struct SQLTokenBoundaryTests { ) #expect(range == NSRange(location: 7, length: 0)) } + + // MARK: - Non-ASCII identifiers + + /// The ASCII-only rule read these tokens as empty, so the replacement range collapsed to zero + /// length and accepting a suggestion inserted beside the typed text instead of replacing it. + @Test( + "A non-ASCII token is replaced, not duplicated", + arguments: [ + ("SELECT 名", "名前", "SELECT 名前"), + ("SELECT 名前", "名前テーブル", "SELECT 名前テーブル"), + ("SELECT имя", "имя_клиента", "SELECT имя_клиента"), + ("SELECT tên", "tên_khach", "SELECT tên_khach"), + ("SELECT café", "café_id", "SELECT café_id"), + ("SELECT Ünvan", "Ünvan_kodu", "SELECT Ünvan_kodu") + ] + ) + func nonASCIISegmentIsReplaced(typed: String, completion: String, expected: String) { + let text = typed as NSString + let range = SQLTokenBoundary.replacementRange( + in: text, cursor: text.length, fallback: NSRange(location: 0, length: 0) + ) + #expect(text.replacingCharacters(in: range, with: completion) == expected) + } + + @Test("A mixed ASCII and non-ASCII token is covered whole") + func mixedScriptSegment() { + let text = "SELECT tê" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length) == 7) + } + + @Test("A surrogate pair is consumed whole rather than split") + func surrogatePairSegment() { + let text = "SELECT 𝕏table" as NSString + #expect(text.length == 14) + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length) == 7) + } + + @Test("A combining mark stays with the base character it sits on") + func combiningMarkSegment() { + let text = "SELECT te\u{0302}n" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length) == 7) + } + + @Test("A non-ASCII token still stops at a dot") + func nonASCIISegmentStopsAtDot() { + let text = "SELECT 顧客.名" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length) == 10) + } + + @Test("Non-identifier punctuation and symbols still end the token") + func nonASCIIPunctuationEndsSegment() { + for text in ["SELECT a、b", "SELECT a b", "SELECT a+b", "SELECT a→b"] { + let ns = text as NSString + #expect(SQLTokenBoundary.segmentStart(in: ns, endingAt: ns.length) == ns.length - 1) + } + } + + /// `$` opens a MongoDB pipeline stage, and `MongoContextAnalyzer` needs the token to start + /// there, so the wider rule must not adopt it. + @Test("A dollar sign still ends the token") + func dollarEndsSegment() { + let text = "aggregate([{ $match" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length) == text.length - 5) + } + + @Test("ASCII segments are unchanged by the wider rule") + func asciiSegmentsUnchanged() { + #expect(SQLTokenBoundary.segmentStart(in: "SELECT mess" as NSString, endingAt: 11) == 7) + #expect(SQLTokenBoundary.segmentStart(in: "SELECT users.na" as NSString, endingAt: 15) == 13) + #expect(SQLTokenBoundary.segmentStart(in: "SELECT " as NSString, endingAt: 7) == 7) + } } diff --git a/TableProTests/Core/Plugins/ClickHouseDialectParityTests.swift b/TableProTests/Core/Plugins/ClickHouseDialectParityTests.swift new file mode 100644 index 0000000000..dbc39c21db --- /dev/null +++ b/TableProTests/Core/Plugins/ClickHouseDialectParityTests.swift @@ -0,0 +1,101 @@ +// +// ClickHouseDialectParityTests.swift +// TableProTests +// +// The ClickHouse dialect is declared twice: once on the plugin, and once in the app's curated +// pre-load table, which is what a completion service gets before the lazy driver bundle activates. +// Nothing at runtime makes the two agree, and both shipped the same 18 uppercase function names +// that the server rejects with UNKNOWN_FUNCTION. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("ClickHouse dialect parity") +struct ClickHouseDialectParityTests { + /// Measured on ClickHouse 26.9.1.52 with `SELECT name, case_insensitive FROM system.functions`. + /// Only the `case_insensitive = 1` rows tolerate any other spelling, and the plugin declares + /// the rest at their exact catalogue spelling, so an uppercased copy is a broken completion. + private static let caseSensitiveSpellings = [ + "toString", "toInt32", "formatDateTime", "uniq", "uniqExact", + "argMin", "argMax", "groupArray", "multiIf", "arrayMap", + "arrayJoin", "match", "currentDatabase", "quantile", "topK", + "trim", "ltrim", "rtrim" + ] + + @Test("The curated pre-load dialect spells every case-sensitive function the way the server does") + func preloadDialectUsesCatalogueSpellings() throws { + let functions = try #require(curatedClickHouseDialect()).functions + let wrong = Self.caseSensitiveSpellings.filter { !functions.contains($0) } + #expect( + wrong.isEmpty, + """ + The curated ClickHouse dialect is served to completion before the driver bundle \ + activates, so these complete to SQL the server rejects: \(wrong.sorted()) + """ + ) + } + + @Test("The curated pre-load dialect declares ClickHouse function names case-sensitive") + func preloadDialectDeclaresCaseSensitivity() throws { + #expect(try #require(curatedClickHouseDialect()).functionNamesAreCaseInsensitive == false) + } + + /// Reads the plugin's own source rather than its type, because a plugin never loads under + /// XCTest. The two lists have no shared constant to point at: one lives in a plugin target and + /// one in the app, so this is what stops them drifting apart again. + @Test("The plugin and the curated table declare the same function vocabulary") + func pluginAndCuratedTableAgree() throws { + let curated = try #require(curatedClickHouseDialect()).functions + let declared = try Self.pluginDeclaredFunctions() + + #expect(!declared.isEmpty, "The ClickHouse plugin source declares a function list") + #expect( + declared == curated, + """ + ClickHousePlugin.swift and PluginMetadataRegistry+RegistryIngredients.swift declare \ + different function vocabularies. Only in plugin: \(declared.subtracting(curated).sorted()). \ + Only in the curated table: \(curated.subtracting(declared).sorted()). + """ + ) + } + + private func curatedClickHouseDialect() -> SQLDialectDescriptor? { + PluginMetadataRegistry.shared.builtInDefaults() + .first { $0.typeId == DatabaseType.clickhouse.rawValue }? + .snapshot.editor.sqlDialect + } + + private static func pluginDeclaredFunctions(file: StaticString = #filePath) throws -> Set { + let source = try repositoryRoot(file: file) + .appendingPathComponent("Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift") + let text = try String(contentsOf: source, encoding: .utf8) + guard let start = text.range(of: "functions: [") else { throw ParityError.sourceNotFound } + guard let end = text.range(of: "]", range: start.upperBound.. URL { + var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + while directory.path != "/" { + let candidate = directory.appendingPathComponent("Plugins/TableProPluginKit/DriverPlugin.swift") + if FileManager.default.fileExists(atPath: candidate.path) { return directory } + directory = directory.deletingLastPathComponent() + } + throw ParityError.sourceNotFound + } + + private enum ParityError: Error { + case sourceNotFound + } +} diff --git a/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/TableProTests/Core/Services/SQLFormatterServiceTests.swift index b6c8259dbf..14a9747d34 100644 --- a/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -330,7 +330,7 @@ struct SQLFormatterServiceTests { @Test("Keywords not uppercased when option is false") func keywordsNotUppercased() throws { var options = SQLFormatterOptions.default - options.uppercaseKeywords = false + options.keywordCase = .preserve let result = try formatter.format("select * from users", dialect: .mysql, options: options).formattedSQL #expect(result.contains("select")) #expect(result.contains("from")) diff --git a/TableProTests/Models/Settings/EditorSettingsKeywordCaseTests.swift b/TableProTests/Models/Settings/EditorSettingsKeywordCaseTests.swift new file mode 100644 index 0000000000..bbf789a669 --- /dev/null +++ b/TableProTests/Models/Settings/EditorSettingsKeywordCaseTests.swift @@ -0,0 +1,94 @@ +// +// EditorSettingsKeywordCaseTests.swift +// TableProTests +// +// Pins the wire format of the keyword case setting, which replaced the +// `uppercaseKeywords` boolean. Editor settings sync as one JSON blob, so both +// keys have to keep working: the new one this build reads, and the legacy one +// an older build on another device still reads. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("EditorSettings keyword case") +struct EditorSettingsKeywordCaseTests { + private func decode(_ json: String) throws -> EditorSettings { + try JSONDecoder().decode(EditorSettings.self, from: Data(json.utf8)) + } + + private func encodedObject(_ settings: EditorSettings) throws -> [String: Any] { + let data = try JSONEncoder().encode(settings) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test("The legacy boolean set to true migrates to always-uppercase") + func legacyTrueMigrates() throws { + #expect(try decode(#"{"uppercaseKeywords": true}"#).keywordCase == .upper) + } + + @Test("The legacy boolean set to false migrates to the typed-case default") + func legacyFalseMigrates() throws { + #expect(try decode(#"{"uppercaseKeywords": false}"#).keywordCase == .matchTypedElseUpper) + } + + @Test("Settings written before either key existed take the default") + func absentKeyTakesDefault() throws { + #expect(try decode("{}").keywordCase == .default) + } + + @Test("The new key wins over a stale legacy boolean beside it") + func newKeyWinsOverLegacy() throws { + let settings = try decode(#"{"keywordCase": "lower", "uppercaseKeywords": true}"#) + #expect(settings.keywordCase == .lower) + } + + @Test("An unrecognised stored value falls back to the default rather than failing to decode") + func unknownValueFallsBack() throws { + #expect(throws: Never.self) { + _ = try self.decode(#"{"keywordCase": "sentenceCase"}"#) + } + } + + @Test("Encoding writes both the new key and the legacy one") + func encodesBothKeys() throws { + var settings = EditorSettings.default + settings.keywordCase = .upper + let upper = try encodedObject(settings) + #expect(upper["keywordCase"] as? String == "upper") + #expect(upper["uppercaseKeywords"] as? Bool == true) + + settings.keywordCase = .matchTypedElseLower + let matching = try encodedObject(settings) + #expect(matching["keywordCase"] as? String == "matchTypedElseLower") + #expect(matching["uppercaseKeywords"] as? Bool == false) + } + + @Test("Every value round-trips") + func roundTrips() throws { + for keywordCase in SQLKeywordCase.allCases { + var settings = EditorSettings.default + settings.keywordCase = keywordCase + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(EditorSettings.self, from: data) + #expect(decoded.keywordCase == keywordCase) + #expect(decoded == settings) + } + } + + @Test("The shipped default leaves keyword rewriting off, as the old toggle did") + func defaultDoesNotRewriteTypedText() { + #expect(EditorSettings.default.keywordCase == .matchTypedElseUpper) + #expect(!EditorSettings.default.keywordCase.rewritesTypedText) + } + + @Test("The other editor settings still decode alongside the new key") + func siblingSettingsUnaffected() throws { + let settings = try decode(#"{"keywordCase": "lower", "tabWidth": 2, "wordWrap": true}"#) + #expect(settings.keywordCase == .lower) + #expect(settings.tabWidth == 2) + #expect(settings.wordWrap) + #expect(settings.showLineNumbers) + } +} diff --git a/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift index de278448f2..809724722e 100644 --- a/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift @@ -160,7 +160,7 @@ struct SQLCompletionProviderFuzzyDedupeTests { let filtered = provider.filterByPrefix(items, prefix: "slc") #expect(filtered.count == 1) - #expect(filtered[0].label == "SSL_CERTIFICATE") + #expect(filtered[0].label == "ssl_certificate") let expectedPenalty = referenceFuzzyScore(pattern: "slc", target: "ssl_certificate") ?? 0 #expect(filtered[0].sortPriority == basePriority) #expect(filtered[0].fuzzyPenalty == expectedPenalty) diff --git a/TableProUITests/EditorAutocompleteFocusUITests.swift b/TableProUITests/EditorAutocompleteFocusUITests.swift index d69697ef09..b259dee957 100644 --- a/TableProUITests/EditorAutocompleteFocusUITests.swift +++ b/TableProUITests/EditorAutocompleteFocusUITests.swift @@ -51,6 +51,30 @@ final class EditorAutocompleteFocusUITests: UITestCase { ) } + /// #2833: the committed keyword takes the case of the typed prefix. Asserted on the exact + /// string rather than a case-folded comparison, which is what the two tests above use and + /// what would have let the old always-uppercase behaviour through. + func testCommittedKeywordTakesTheTypedCase() throws { + let app = try launchWithSampleDatabase() + + let editor = editorTextView(in: app) + for (typed, expected) in [("sel", "select"), ("SEL", "SELECT")] { + app.typeKey("t", modifierFlags: .command) + XCTAssertTrue(editor.waitToExist(timeout: 10)) + XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "New tab editor should start empty") + + app.typeText(typed) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) + app.typeKey(.return, modifierFlags: []) + + XCTAssertTrue( + waitForValue(expected, in: editor, timeout: 5), + "Typing '\(typed)' and accepting should commit '\(expected)'; got " + + "'\(editor.value as? String ?? "nil")'" + ) + } + } + private func waitForValue( in element: XCUIElement, timeout: TimeInterval, diff --git a/docs/customization/editor-settings.mdx b/docs/customization/editor-settings.mdx index c4367eae85..8896100e26 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -23,7 +23,7 @@ Editor fonts are per-theme. Edit them on the theme's Fonts tab under [Appearance | Code folding | On | Shows the fold ribbon in the gutter. See [Code Folding](/features/code-folding) | | Run button beside each statement | On | Gutter run buttons, revealed when the pointer is over the gutter | | Tab width | 4 spaces | 2, 4, or 8 | -| Auto-uppercase keywords | Off | Uppercases SQL keywords on word boundaries. Strings, comments, and quoted identifiers are untouched | +| Keyword case | Match what I type, otherwise UPPERCASE | The case completed keywords and functions take, and the case **Format SQL** produces. `UPPERCASE` and `lowercase` also rewrite keywords as you type them, leaving strings, comments and quoted identifiers untouched. See [Keyword case](/features/autocomplete#keyword-case) | | Query parameters (`:name` syntax) | On | Detects `:name` placeholders and shows the parameter panel. See [Query Parameters](/features/query-parameters) | | Vim mode | Off | Modal editing in the SQL editor. See [Vim Mode](/features/vim-mode) | diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 08b9859a2e..11757ebad0 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -59,7 +59,7 @@ What a fresh install ships with, so one glance down this column says what you ch | Editor | Run button beside each statement | On | | Editor | Show invisible characters | On | | Editor | Tab width | 4 spaces | -| Editor | Auto-uppercase keywords | Off | +| Editor | Keyword case | Match what I type, otherwise UPPERCASE | | Editor | Query parameters | On | | Editor | Vim mode | Off | | Data | Row height | Normal | diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index f4dceb9869..c585e6270c 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -27,7 +27,7 @@ It opens itself after FROM, JOIN, ON and the other clauses with a short answer, | `Return` / `Tab` | Accept the selected suggestion | | `Escape` | Dismiss and keep typing | -There is no setting for autocomplete and nothing to turn off. What the popup offers depends entirely on where the cursor sits. +Autocomplete is always on and there is nothing to turn off. What the popup offers depends entirely on where the cursor sits; the one setting is the case keywords come back in. ## SQL keywords @@ -36,6 +36,30 @@ SEL| -- SELECT FROM users WH| -- WHERE ``` +## Keyword case + +Keywords and built-in functions come back in the case you are typing: + +```sql +sel| -- select +SEL| -- SELECT +cou| -- count() +COU| -- COUNT() +``` + +The first letter decides, so `Sel` completes to `SELECT` and `sElE` to `select`. A phrase moves as one: `group b` completes to `group by`. Table, column, schema and alias names are never re-cased, and neither are saved [favorite keywords](#favorite-keywords) or the values of an enum column. + +**Settings > Editor > Keyword case** holds four values: + +| Value | Typing `sel` | Nothing typed | +|-------|--------------|---------------| +| Match what I type, otherwise UPPERCASE | `select` | `SELECT` | +| Match what I type, otherwise lowercase | `select` | `select` | +| UPPERCASE | `SELECT` | `SELECT` | +| lowercase | `select` | `select` | + +The two absolute values also rewrite keywords as you type them and set the case [Format SQL](/features/sql-editor#formatting) produces. + ## Table names Tables appear after FROM, JOIN, INSERT INTO and similar keywords, and lead the list ahead of keywords there. The clause is read at the cursor, so a second or third JOIN still offers tables. diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 66a46495bc..ed77fc62d5 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -134,7 +134,7 @@ The editor also carries multiple cursors, for editing several places at once. Press `Cmd+Shift+L` to format the statement at the cursor. The toolbar's format button and **Query > Format Query** do the same, and the shortcut is rebindable in **Settings > Keyboard**. -The token-based formatter breaks a line per clause, indents two spaces, uppercases keywords, and keeps your comments, string literals, cursor position and dialect identifier quoting (MySQL backticks, PostgreSQL double quotes). JOINs, subqueries, CASE expressions, recursive CTEs, window functions and set operations are all handled. Procedural blocks pass through with minimal changes: PL/pgSQL `DO`, stored procedures, T-SQL `BEGIN`/`END`. +The token-based formatter breaks a line per clause, indents two spaces, cases keywords per **Settings > Editor > Keyword case**, and keeps your comments, string literals, cursor position and dialect identifier quoting (MySQL backticks, PostgreSQL double quotes). JOINs, subqueries, CASE expressions, recursive CTEs, window functions and set operations are all handled. Procedural blocks pass through with minimal changes: PL/pgSQL `DO`, stored procedures, T-SQL `BEGIN`/`END`. **Before**: ```sql @@ -191,6 +191,6 @@ For the execution plan rather than an opinion, press `Cmd+Option+E` and see [Exp ## Editor settings -**Settings > Editor** holds line numbers, current-line and current-statement highlighting, word wrap, [code folding](/features/code-folding), the per-statement run button, [invisible characters](#invisible-characters), tab width, auto-uppercase keywords, [query parameters](/features/query-parameters), and [vim mode](/features/vim-mode). Editor font family and size are per theme, in **Settings > Appearance**. +**Settings > Editor** holds line numbers, current-line and current-statement highlighting, word wrap, [code folding](/features/code-folding), the per-statement run button, [invisible characters](#invisible-characters), tab width, [keyword case](/features/autocomplete#keyword-case), [query parameters](/features/query-parameters), and [vim mode](/features/vim-mode). Editor font family and size are per theme, in **Settings > Appearance**. Editor windows remember their size, position and font zoom between launches. See [Query Tabs](/features/tabs#switching-tabs).