Skip to content

fix(completions): quote names that need it when inserting them - #981

Open
psteinroe wants to merge 3 commits into
mainfrom
fix/completions-quote-names
Open

psteinroe wants to merge 3 commits into
mainfrom
fix/completions-quote-names

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #976.

Completions inserted names exactly as they are stored. Postgres downcases unquoted identifiers and rejects most keywords as names, so the inserted text often meant a different name:

create table "MyTable" ("Id" int, "select" int);
create function "My Schema".helper() returns int ...;

select | from "MyTable"      -- before: Id, select   now: "Id", "select"
select * from |              -- before: MyTable      now: "MyTable"
select |                     -- before: My Schema.helper()   now: "My Schema".helper()
select | from users          -- before: pg_catalog.RI_FKey_cascade_del()   now: pg_catalog."RI_FKey_cascade_del"()

What changed

  • pgls_catalog::quote_identifier(name, position) and needs_quotes, next to identifier_name, of which they are the inverse. A name stays unquoted only if it's an identifier by scan.l's rules that downcasing leaves unchanged, and if it isn't a keyword the grammar rejects at that position (name classification in gram.y):
    • ColId (the first part of a table, column or schema name) rejects reserved keywords and those that name types or functions.
    • type_function_name (an unqualified function) rejects reserved keywords and those that name columns.
    • After a dot, any keyword is fine.
    • Otherwise the name is quoted, with " escaped as "". This is more precise than Postgres' own quote_identifier, which quotes every keyword that isn't unreserved.
  • The keyword categories come from pgls_lexer, which already generates them from kwlist.h. It now has a public keyword_category(word), and pgls_catalog depends on pgls_lexer.
  • with_schema_or_alias takes the position of the item:
    • Without quotes, the prefix is quoted as a ColId and the item after the dot as a label.
    • After an opening quote ("| or "|"), only " is escaped, and no second quote is added.
    • Aliases are passed in as names, so from users "pr" now inserts pr.email instead of "pr".email. Both refer to the same alias.
  • Schemas now set a completion text, so they're quoted too. After an opening quote, they also get the closing one: select * from "| inserts private".

Snapshot changes

  • pg_catalog.RI_FKey_cascade_del() is now pg_catalog."RI_FKey_cascade_del"().
  • After an opening quote, schemas get their closing quote.
  • "pr".email is now pr.email.

Tests: unit tests for needs_quotes/quote_identifier, covering the four keyword categories, uppercase, spaces, ", a leading digit, $, non-ASCII, and the round trip through identifier_name. A completions test covers tables, schemas, schema-qualified tables, the opening-quote case, columns (keywords quoted alone but not after a dot), and functions with and without a schema. pgls_catalog, pgls_lexer, pgls_completions, pgls_workspace --lib and clippy pass.

Not done: the pretty printer keeps its own needs_quoting in pgls_pretty_print/src/nodes/string.rs. It uses its codegen's keyword table, and sharing the predicate would mean a new dependency for pgls_pretty_print and changes in string.rs while its PR stack is open. It can switch to pgls_catalog::needs_quotes, or the predicate can move into pgls_lexer, once that stack has landed. Completing while typing inside an unterminated quote ("My|) still returns nothing, as on main. Policies and roles are still inserted unquoted.

Update: Merged main, now that #976 has landed (the merge only takes main's version of the #976 changes; this PR's own diff is unchanged). cargo clippy --workspace --all-targets -- -D warnings and the tests of pgls_catalog, pgls_lexer and pgls_completions pass on the merge. #998 is stacked on this PR and switches expand star to quote_identifier.

Turn identifiers as written into names with one function,
pgls_catalog::identifier_name, like Postgres' scanner: unquoted
identifiers are downcased (ASCII only), quoted ones lose their quotes
and unescape "". Hover, completions, the tree-sitter context, inlay
hints and identifierTooLong use it, and Snapshot's find_* lookups now
take names.

Completions no longer lowercase the whole statement, so quoted names
keep their case, and the grammar reads "a""b" as one identifier.
Completions inserted table, column, function and schema names as they
are stored, so MyTable, names with spaces or quotes, and reserved
keywords were read by Postgres as another name, or not at all. They are
now quoted, with " escaped as "", exactly where Postgres needs it:
pgls_catalog::quote_identifier follows scan.l's identifier rules and
the keywords that gram.y accepts at each position.
@psteinroe
psteinroe changed the base branch from fix/identifier-names to main October 26, 2026 11:58

This branch has not been deployed

No deployments
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