Skip to content

feat(langservice): function-completion items insert with arg-placeholder snippets#45

Draft
joewiz wants to merge 5 commits into
eXist-db:developfrom
joewiz:feat/completion-function-arg-snippets
Draft

feat(langservice): function-completion items insert with arg-placeholder snippets#45
joewiz wants to merge 5 commits into
eXist-db:developfrom
joewiz:feat/completion-function-arg-snippets

Conversation

@joewiz

@joewiz joewiz commented Jun 5, 2026

Copy link
Copy Markdown
Member

[This PR was co-authored with Claude Code. -Joe]

Draft — stacked on #42 (completions scoping + LSP-shaped output). The diff currently shows #42's commits too; once #42 merges into develop, this branch will be rebased and the diff will shrink to just the one commit added here. Please don't review until #42 is merged.


The third item from the oxygen-plugin team's recommendations on #44: when a user picks a function from completions, drop them into tab stops for each argument instead of a bare name(). Matches the "Template:" line in eXide's F1 Function Documentation panel, delivered through the LSP-standard CompletionItem.insertText + insertTextFormat channel so every LSP-aware client gets it.

Behaviour

Cursor label insertText format
cou (bare) fn:count#1 count(${1:\$items}) 2 (Snippet)
fn:cou fn:count#1 fn:count(${1:\$items}) 2
util:l util:log#2 util:log(${1:\$priority}, ${2:\$message}) 2
fn:tr fn:true#0 fn:true() 1 (Plain — zero-arity)

Tab stops default to the parameter's declared XQuery name (e.g. $items for fn:count's parameter), so accepting the completion drops the user at the first $items highlighted as the default — they can type to replace, or Tab to move on. Multi-parameter functions cycle through each placeholder in order.

LSP clients without snippet support fall back to inserting insertText verbatim per spec — the placeholders render as visible text (slightly noisy but correct).

Implementation

formatInsertText(prefix, sig) walks sig.getArgumentTypes(), downcasting each to FunctionParameterSequenceType to read the declared name (XQuery built-ins register these via FunctionDSL.param(…, "$name", …)). Falls back to arg1/arg2/… when no declared name is available. Zero-arity functions get no placeholders and the existing plain format — insertFormatFor(sig) returns SNIPPET (2) only when arity > 0.

Same treatment for user-declared functions: their argument names come through the same FunctionParameterSequenceType API on the user function's signature.

Tests

  • bare-mode fn:* drops the prefix from insertText (snippet form) — updated to assert the new snippet body for fn:count
  • prefixed-mode fn:* keeps the prefix the user typed (snippet form) — updated for prefixed shape
  • non-fn namespaces always keep their prefix in insertText — updated to check the snippet pattern
  • zero-arity functions stay plain (no snippet placeholders) — new; pins fn:true#0 to plain format=1

All 22 langservice cypress tests pass; PMD clean.

Closes the oxygen-plugin recommendation list

This completes items 1–3 from the oxygen-plugin team's PR #44 feedback:

joewiz added 5 commits June 4, 2026 22:31
…layer 1)

When the cursor is at `prefix:` or `prefix:partial`, only that
namespace's functions are returned (and the local-name is prefix-matched
case-insensitively when present). Keywords are dropped from prefixed
responses since `util:return`/`util:let` can't exist. Bare/empty cursors
get the full set unchanged — current behaviour for that case.

Cuts the typical wire payload from ~930 items to ~10–100 for any
prefixed cursor (e.g. `util:` → 104, `fn:cou` → 1).

User-declared symbols (functions + global variables) get the same
scoping. Variables are never offered in prefixed mode.

Cypress: 5 new tests covering prefixed scoping, local-name match,
keyword suppression, full-set fallback, and multi-line trailing-token
detection.

Refs eXist-db#31
 layer 2)

Every completion item now carries three additional fields the LSP
contract expects:

- filterText — what the client matches typed input against. For
  functions, this is the local-name only, so bare `cou` matches
  `fn:count` (which would not match if the client filtered against the
  full label `fn:count#1`).
- sortText  — bucketed by namespace via a small table; `fn:*`, keywords,
  user-declared fns and vars all bucket to "0_…" so an unprefixed `cou`
  surfaces `fn:count` above `util:count*`, `range:count*`, etc.
- insertTextFormat — LSP 1 (PlainText) on every existing item; the
  field is in place for layer 3 (snippets, format 2).

In addition, bare-mode `fn:*` items now drop the `fn:` prefix from
`insertText`, so accepting a completion for `cou` yields `count(...)`
rather than `fn:count(...)` — preserves the user's unprefixed style.
Prefixed-mode (`fn:cou`) keeps the prefix the user already typed. Other
namespaces always keep their prefix in insertText since the function
literally can't be called without it.

Cypress: 5 new tests covering field presence, bare-fn prefix drop,
prefixed-fn prefix retention, non-fn prefix retention, and sortText
bucketing.

Refs eXist-db#31
Adds 7 snippet items (FLWOR for/let, if/then/else, try/catch,
typeswitch, declare function, import module) that expand tab-stop
placeholders when accepted in clients that honor LSP
\`insertTextFormat: 2\`. Clients that don't fall back to plain-text
insertion per the LSP spec.

Snippets are only emitted in bare/empty cursor mode — never after
\`prefix:\` since \`util:for\` etc. can't exist. They bucket to
sortText "0_<trigger>" so typing "fo" or "tr" surfaces the snippet
alongside matching keywords and fn:* functions.

The \`for\` snippet body, for example, expands to:

    for $x in expr
    return $x

with the user's tab stops landing on \`x\` → \`expr\` → \`$x\` (the
return clause, defaulting to a back-reference).

Cypress: 2 new tests covering snippet emission in bare mode and
suppression in prefixed mode.

Refs eXist-db#31
)

Documents what the server does for completions scoping, sortText
biasing, insertText shaping, and snippets — and what client
implementers are expected to do on top of it (cache per trigger session,
filter against filterText not label, trust sortText, honor
insertTextFormat: 2). Includes a practical sequencing section noting
that scoping makes interactive use one round-trip per context change,
not per keystroke.

Refs eXist-db#31
…der snippets

Accepting a function completion now inserts a snippet body with one tab
stop per parameter, defaulting to the parameter's declared XQuery name:

  cou → count(${1:\$items})            insertTextFormat: 2
  fn:tr → fn:trace(${1:\$value})       insertTextFormat: 2
  util:log → util:log(${1:\$priority}, ${2:\$message})
  fn:tr → fn:true()                    insertTextFormat: 1  (zero-arity stays plain)

Matches the eXide F1 Function Documentation panel's "Template" line, but
delivered through the LSP-standard CompletionItem.insertText +
insertTextFormat mechanism so any LSP-aware client gets it. Clients
without snippet support fall back to plain-text insertion per the LSP
spec (the placeholders render as visible text, slightly noisy but
correct).

Implementation: extracted formatInsertText(prefix, sig) that walks
sig.getArgumentTypes(), downcasting each to FunctionParameterSequenceType
to get the declared param name (e.g. "items" for fn:count's $items).
Falls back to "arg1"/"arg2"/... if a parameter has no declared name.
Zero-arity functions get no placeholders and the existing plain
insertTextFormat (1). Build-time check: insertFormatFor(sig) picks the
right format per sig.

Cypress: existing prefix-drop / prefix-keep / non-fn tests updated to
assert the new snippet body; one new test pins zero-arity to plain
format.

Refs eXist-db#31's Layer 3 (snippets in keyword-trigger items); this is the
analogous treatment for function items themselves.
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