Skip to content

feat(workspace): one extractor for the statements of SQL function bodies - #997

Open
psteinroe wants to merge 1 commit into
chore/remove-dead-code-rebuildfrom
feat/function-body-statements
Open

psteinroe wants to merge 1 commit into
chore/remove-dead-code-rebuildfrom
feat/function-body-statements

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #989.

This is the first PR of a rebuild of #918, #929, #930 and the PRs stacked on them. Those built two cache systems and three walks over the statements of a document, side by side. The rebuild replaces them with one design, in a smaller stack from main. The design comes first, then what this PR does.

The design

1. One extractor for embedded statements. pgls_query_ext::function_body is the only code that finds the statements of a SQL function body: '...', E'...' and $$...$$ strings, BEGIN ATOMIC ... END, and RETURN expr. Each is an EmbeddedStatement { sql, range, offsets }: the text that is parsed, where it is in its CREATE FUNCTION, and an OffsetMap between the two. The workspace, the analyser's test harnesses and the Postgres regression harness use it.

2. The document owns its statements. The document's iterator yields DocumentStatement { id, range, offsets } for top-level and embedded statements. The embedded statements of a top-level statement are computed once per statement text, next to its parse tree. Features map spans with document_range/statement_offset on the statement and never add range.start() by hand.

3. One walk. pgls_analyser::walk_statements is the only loop that follows the catalog and the session through the statements of a file, with the migration sections, unparsable statements, function bodies and identifier parameters handled once. Lint runs its rules in it; hover, completions, inlay hints, semantic tokens and code actions resolve in it. It can resume from a WalkState taken at a top-level statement.

4. One cache per document, in one module. server/document_cache owns everything a document caches, and its types are private to it:

  • per statement text: the parse tree, the embedded statements, the PL/pgSQL check, the tree-sitter tree;
  • per CacheKey (the settings version and the identity of the catalog base, which is built from the database snapshot): the analysis, a few walk checkpoints, the resolved references of the statements that requests looked at, and the snapshot a statement sees.

An edit keeps every entry whose top-level statement starts before the change and whose preceding statements didn't move, and drops the rest. That is one rule for all key-dependent entries, so there is no hashing of the text before a statement on every request.

5. One memory budget. A CacheBudget across documents evicts the caches of the least recently used documents when all of them together are over the limit. It counts the entries of all the caches above, and the memory and edit-churn tests from #951/#953/#956/#957 check it.

6. Requests off the runtime. Documents are shared as Arc<Document>; editor requests run on the blocking pool on a snapshot of the document, stop when it changes or the request is cancelled, and entries computed on an outdated version are not stored.

The stack

PR What Replaces
#988 move formatting out of server.rs #975
#989 remove dead code #977
#997 (this) one extractor for function body statements (1, 2) #918, #941
#1002 one walk, sharing the parse trees (3) #974, #965
#1003 the document_cache module, moves only (4)
#1004 cache the analysis (4) #929
#1005 keep what the walk resolves: checkpoints, resolved statements, completions lookup (3, 4) #930, #982, #984
#1006 one memory budget, memory and edit-churn tests (5) #951, #953, #956, #957
#1007 split only the statements around a change #964
#1008 editor requests off the runtime, cancellation (6) #931, #954, #961
#994 (beside, on main) inlayHints.parameterNames #948

This PR

Statements in BEGIN ATOMIC bodies, RETURN bodies, and '...' or E'...' bodies with escapes got no semantic tokens, inlay hints, hover, or lint diagnostics, because the body was found with content.find(&body) on the unescaped body. That also matched an earlier occurrence of the same text, like a parameter default 'select 1' before as 'select 1'.

create function atomic_fn(p int) returns int language sql
begin atomic select p + id from users; end;
-- before: nothing; after: p is a parameter, id a column, users a table, hover works

create function quoted_fn(p text) returns text language sql
as 'select name || ''x'' || p from users where nope = 1';
-- before: nothing; after: tokens and hover, and `unknownColumn` on `nope` at the right place

What changed

  • pgls_query_ext::function_body (replaces utils::sql_function_body and SqlFunctionBody::statements):
    • AS '...', AS E'...', AS $tag$...$tag$: the string constant right after an AS token whose value is the body. The value of '...' and E'...' is read like Postgres' lexer reads it (scan.l at REL_18_6): '', \b \f \n \r \t \v, octal, \x, \u/\U, and \ before any other character. The body is split into statements like before (fix(workspace): analyse every statement of a SQL function body #947), and each statement gets the part of the offset map that covers it.
    • BEGIN ATOMIC: one statement per ;, tracking parentheses and CASE ... END, up to the END that starts a statement. If the count doesn't match the parsed body, there are none.
    • RETURN expr: resolved as select expr, which has the same length. The map marks select as standing for return, so the text counts as rewritten.
    • OffsetMap::is_verbatim tells whether a statement's text is its source. Fixes are only kept on verbatim statements.
    • function_body_location locates the string body of a function in any language. The PL/pgSQL syntax error uses it, instead of content.find, and covers the whole statement if the body can't be found instead of being skipped.
  • pgls_analyser::AnalysableStatement carries the OffsetMap, with document_range, document_offset and statement_offset. The analyser places diagnostics with it. AnalysableStatement::embedded builds one from an EmbeddedStatement, which is all the test harnesses still do themselves.
  • Workspace: PgQueryStore::get_or_cache_embedded keeps the embedded statements of a statement next to its parse tree. ParseIterator yields them as DocumentStatements, and mappers and filters take a DocumentStatement. Hover, completions (CompletionsResult::in_document), inlay hints and semantic tokens map through it. The expand-* action is not offered in a statement whose text isn't verbatim, since its edit would need escaping.
  • Docs: the limitations of the editor features now only list U&'...' and PL/pgSQL bodies.

Tests

  • function_body: splitting string bodies, a default with the same text as the body, $body$ quoting, '' mapped through two statements of one body, E'...' escapes, the unescaping rules and what isn't matched, BEGIN ATOMIC with CASE ... END, RETURN, and the offset map in both directions.
  • pg_query: PL/pgSQL syntax error spans for a body with '', and for a body whose text also appears earlier, in a parameter default.
  • Workspace: test_sql_function_bodies (tokens, hover and an unknownColumn diagnostic in BEGIN ATOMIC, RETURN, '' and E'...' bodies), and formatting of a '' body (now formatted like other bodies, into $$) and an idempotent BEGIN ATOMIC function.
  • The Postgres regression snapshots now count 23 to 27 more body statements per version. The three new findings per version are all on statements Postgres rejected (create_function_sql.sql:203 and two in window.sql), so the test passes.
  • pgls_analyser, pgls_query_ext, pgls_workspace and pgls_lsp pass, and clippy on the whole workspace.

Cost

Each top-level statement is scanned once per statement text to find its body, only if it is a CREATE FUNCTION of a SQL function, and the result is cached with its parse tree. Before, every walk over the document searched each SQL function's text for its body again.

Not covered

  • U&'...' bodies, and editor features in PL/pgSQL bodies (only the syntax check).
  • Completions inserted into an escaped body are not escaped.

pgls_query_ext::function_body finds the statements of '...', E'...' and
$$ bodies by the token after AS, BEGIN ATOMIC statements, and RETURN
expressions, with an OffsetMap from each statement's text to its source.
The document caches them per statement and hands out DocumentStatements
that carry the map, so the analyser and the editor features place spans
through it. The test harnesses use the same extractor.

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