Skip to content

feat(editor): match SQL autocomplete case to the typed prefix - #2902

Merged
datlechin merged 1 commit into
mainfrom
feat/autocomplete-keyword-case-2833
Sep 16, 2026
Merged

datlechin merged 1 commit into
mainfrom
feat/autocomplete-keyword-case-2833

Conversation

@datlechin

Copy link
Copy Markdown
Member

Completing sel now inserts select, and SEL inserts SELECT, in the popup label as well as the inserted text. Closes #2833.

Root cause

Case was decided at item construction, by a factory that cannot know what the user typed and is shared by vocabularies whose spelling is fixed. SQLCompletionItem.keyword() wrote keyword.uppercased() into both label and insertText, on a struct built in getCandidates long before ranking, and the accept path faithfully inserted that constant.

That is two defects at once: the case was decided too early, and it was decided on the wrong axis. kind == .keyword is not a usable discriminator, because it also carries value literals ('active'), * and users.*, plugin statement completions, and MongoDB's $match vocabulary. Re-casing by kind would rewrite user data.

The second defect already shipped: MongoDB autocomplete offered $MATCH and DB, and the server rejects both.

The fix

The missing concept is that the vocabulary declares whether its spelling is fixed.

  • SQLCompletionItem gains caseFolding, defaulting to .fixed. Only SQL keywords, generic built-in functions, operators and DDL type names opt into .caseInsensitive.
  • SQLCompletionCasing is a pure transform applied at one chokepoint, CompletionEngine, which every SQL completion item passes through on its way to a UI surface. That covers the editor popup, the filter panel's Raw SQL field and the compare scope editor in one place. The session's candidate pool stays canonical, so a longer prefix folds from the vocabulary's own spelling.
  • SQLKeywordCase replaces the uppercaseKeywords boolean and drives the popup label, the inserted text, the as-you-type rewriter and Format SQL, so those three can no longer disagree.

Behaviour

Typed Inserted
sel select
SEL SELECT
Sel SELECT
sElE select
cou count(), caret between the parens
group b group by

The first cased character decides and nothing else. This is psql's rule: pg_strdup_keyword_case() branches on islower(ref[0]) alone, with no per-character mirroring and no Capitalised arm. Vim's infercase and Emacs' dabbrev agree that one leading capital is not all-caps intent.

The four setting values are psql's COMP_KEYWORD_CASE. The two Match what I type values differ only in the case they fall back to when the prefix has no cased character, which happens whenever the popup opens on an empty token. The default, Match what I type, otherwise UPPERCASE, reproduces the shipped output for every case that has no typed signal.

Identifiers are never re-cased. DBeaver's Insert case does re-case them and lower-cased a real table name out of existence (dbeaver/dbeaver#12054); psql's identifier_needs_quotes() excludes catalog identifiers from the keyword-case path for the same reason. #2527 keeps its own per-connection identifier setting and its quoting rule.

Why not decide at accept time

SQLCompletionInsertion.resolve is the cheaper place, but it runs only on accept, after the popup has already drawn the label, and the filter panel never called it at all. The request names both the label and the inserted text.

Included because the primary fix is not safe or complete without them

MongoDB inserted $MATCH. MongoCompletionService routed $match, $group, db and every collection method through the uppercasing factory. Its whole vocabulary is JavaScript and case-significant, so it is marked .fixed explicitly rather than inheriting a default.

ClickHouse offered 18 function names the server rejects. Read from the server's own catalog on 26.9.1.52: SELECT name, case_insensitive FROM system.functions. 18 of the 36 names the dialect declared are case_insensitive = 0, so TOSTRING, UNIQ, MULTIIF, ARRAYJOIN, TOPK, TRIM, LTRIM, RTRIM, MATCH, QUANTILE and the rest were completions that produce Code: 46 UNKNOWN_FUNCTION. Corrected to the catalog spellings. SQLDialectDescriptor.functionNamesAreCaseInsensitive is what stops the new transform re-casing them into a different wrong spelling; ClickHouse is the only dialect that sets it false, and the generic pool stays case-insensitive there because count, if, now, cast, concat, lower and substring are all case_insensitive = 1.

Completion duplicated text after a non-ASCII prefix. SQLTokenBoundary.isIdentifierChar accepted only A-Z a-z 0-9 _, and it defines the typed prefix both for the analyzer and for the accept path's replacement range. Typing SELECT 名 and accepting 名前 gave SELECT 名名前. The backward walk now steps by composed character sequence, so surrogate pairs and combining marks are consumed whole. $ is deliberately still excluded, because MongoDB's analyzer needs a stage token to start there. ASCII behaviour is byte-identical.

The formatter had no lowercase mode. SQLFormatterOptions.uppercaseKeywords was a boolean whose false branch kept the spelling already in the statement, except in handleJoinPrefix, which lowercased. Wiring Keyword case to it would have made Format SQL claim a lowercase it does not produce. It is now a three-state SQLFormatterKeywordCase, and preserve keeps the old false behaviour for the one caller that used it.

The ClickHouse dialect is declared twice. The app's curated pre-load table carried a verbatim copy of the same broken list, and it is what a completion service gets before the lazy driver bundle activates. Nothing at runtime made the two agree, so ClickHouseDialectParityTests reads the plugin's source and the curated table and fails when their function vocabularies diverge.

The filter panel bypassed the shared insertion rule. SQLCompletionInsertion.resolve had exactly one production call site. The filter field spliced raw text and hard-coded the caret after it, so accepting COUNT there left the caret outside the parentheses. Both surfaces now share one rule.

PluginKit

SQLDialectDescriptor gains one field, added through a new init overload with the previous designated init marked @_disfavoredOverload, so no already-built plugin loses a symbol. currentPluginKitVersion is already 31 for this cycle (v0.74.0 shipped 30) and every plugin Info.plist already stamps 31, so this additive change reuses the pending number: no new bump, no minimumCompatiblePluginKitVersion change, and no bulk re-release.

Not in scope

  • Match the server's identifier case when completing on Snowflake #2527, the per-connection identifier case for Snowflake and the case policy for Copy as and the query builder. This PR guarantees only the safe half: an identifier is never re-cased.
  • Plugin-supplied statementCompletions stay fixed-spelling for the twelve engines that populate them. Freezing curated per-plugin vocabulary is the safe default; giving CompletionEntry its own folding flag is a separate change.
  • Type names after :: keep their existing lower-case spelling. A cast and a CREATE TABLE column use two different conventions for one vocabulary, and one setting cannot express both. Changing it would have regressed SELECT id:: from integer to INTEGER for the default setting.

Before / After

Typing sel in a query tab, driven against a throwaway sandbox on a Debug build. Same frame in both.

Before (and today with Keyword case = UPPERCASE, which reproduces the old behaviour exactly):

Typing sel and the popup offering SELECT

After (the shipped default, Match what I type, otherwise UPPERCASE):

Typing sel and the popup offering select

An uppercase prefix still gets an uppercase keyword, so this follows what you type rather than just lowercasing:

Typing SEL and the popup offering SELECT

The setting that replaces the old Auto-uppercase keywords toggle:

Settings, Editor, showing the Keyword case picker

The Keyword case picker open with its four values

Verification

All through .claude/skills/fix-issue/scripts/verify.sh.

Step Result
generate PASS
build PASS
test (17 suites owning the changed types) PASS, 372 of 372 cases
lint (every changed path) clean; the two remaining hits are on main at the same lines, in TableProTests/, which .swiftlint.yml's included: never reaches
docs PASS
abi vs merge base diff present and adjudicated additive: one field on a non-frozen struct, one new init, and @_disfavoredOverload on the previous designated init. The single removed interface line reappears verbatim with only that attribute prepended, so no symbol disappeared
plugins (AllPlugins) fails only inside the OracleNIO package on a @TaskLocal macro, with zero non-macro errors. Pre-existing and unrelated
every dialect-declaring plugin, built individually PASS for Beancount, Cassandra, Dameng, DuckDB, MSSQL, Teradata, plus ClickHouse, MySQL, PostgreSQL and SQLite via the app build
uitest could not run here. The runner blocks on a Touch ID prompt, XCTest is trying to Enable UI Automation, and times out with Timed out while enabling automation mode. Twice. The new case is written, is not quarantined, and CI runs TableProUITests sharded

The ClickHouse facts are measured, not inferred: SELECT name, case_insensitive FROM system.functions against 26.9.1.52, plus direct execution of each spelling.

Reviewed by Codex (review --scope working-tree). It raised two findings, both real and both fixed here: the formatter's missing lowercase mode, and the duplicated ClickHouse dialect in the app's pre-load table.

@mintlify

mintlify Bot commented Sep 15, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 15, 2026, 5:53 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit 346f7c1 into main Sep 16, 2026
9 of 14 checks passed
@datlechin
datlechin deleted the feat/autocomplete-keyword-case-2833 branch September 16, 2026 02:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant