Language Server (LSP) Tutorial#
This tutorial covers scafctl lsp, which runs a Language Server Protocol (LSP)
server so editors get a full authoring experience for solution files – live
diagnostics, navigation, rename, an outline, hover, completion, signature help,
and quick fixes – all consistent with the scafctl CLI.
Overview#
scafctl lsp speaks LSP over stdin/stdout. An editor / LSP client launches it,
sends the documents you open and edit, and the server answers requests. It is
not meant to be run interactively – stdout is the JSON-RPC channel.
flowchart LR A["Editor<br/>(LSP client)"] <-->|"stdio JSON-RPC"| B["scafctl lsp"] B --> C["lint / schema / reference index"] C -->|"diagnostics, symbols, completions, ..."| B B -->|"LSP responses"| A
Everything the server surfaces comes from the same sources the CLI already uses
– the lint engine, the positioned reference index (refindex), the generated
solution schema, and the built-in function registries – so the editor never
disagrees with scafctl lint, scafctl refactor, or the schema. The bundled VS
Code extension is a thin client over this server; the gestures below are written
for it, but any LSP client exposes the same capabilities.
Diagnostics#
On open/change/save the server lints the in-memory document and reports each
finding (severity, message, rule name) at its source location, using the same
engine as scafctl lint – so what you see in the editor matches the CLI.
Try: reference an undefined resolver in a _. expression; the unknown-resolver
diagnostic appears inline as you type, exactly as scafctl lint would report it.
Navigation and rename#
Resolver, action, call, and function references are navigable and support
renaming, reusing the same reference index and rename engine as
scafctl refactor rename:
- Go to definition on a reference jumps to where it is defined.
- Find all references lists every use of a resolver, action, call, or function.
- Rename updates a symbol’s definition and every reference in one edit. If any reference cannot be located, the rename is refused (the editor shows the error) rather than applying a partial, solution-breaking change.
Try: put the cursor on a resolver name and press F2 (Rename) – every _.name,
rslvr: name, and dependsOn entry updates together; or F12 (Go to Definition)
on a reference to jump to its declaration.
Outline#
textDocument/documentSymbol returns a hierarchy – a spec root whose children
are the resolvers, actions, calls, and functions groups, each listing its
symbols.
Try: open the Outline pane, or press Cmd/Ctrl+Shift+O to fuzzy-jump to any resolver, action, call, or function; the breadcrumb bar tracks your position.
Hover#
Hovering a symbol shows contextual markdown pulled from the sources the CLI already knows about – no separate documentation to maintain:
- Resolver / action / call / function reference -> its kind, name, and
descriptionfrom the solution. - Provider name (a
provider:value) -> the provider’s description and a summary of its input schema (each input’s name, type, whether it is required, and its doc), straight from the provider descriptor. - CEL / template function -> the function’s signature (CEL), description, and
a usage example, from the built-in function registries (the same catalog
scafctluses everywhere else). - Mapping key -> the schema documentation for that field, plus a related concept summary when one matches.
Hover degrades gracefully: on an unknown target, a parse error, or whitespace it simply returns nothing, so it never interrupts editing.
Try: hover a provider: value to read its inputs, or a _.name reference to
read the target resolver’s description.
Completion#
As you type, the server offers completions from the schema and the reference index:
- Keys – typing a key under a known path offers the valid child keys from the
generated solution schema (e.g. under a resolver:
description,resolve,dependsOn, …; under a provider entry:provider,inputs,onError, …), filtered by what you have typed. Each suggestion carries its type and field documentation. - Enum values – an enum-valued field (
onError,backoff,resultSchemaMode,scope) offers its allowed values. - Functions – inside a CEL expression the server offers CEL functions (with
their signatures); inside a
{{ }}template it offers Go-template functions and the solution’s own author-defined helpers (spec.functions). Each is inserted as a call snippet with the cursor at the first argument, using the syntax valid for that context: a parenthesized call in CEL (name($0)) and a space-separated call in templates (name $0, sincename()is not valid template syntax). - Symbols – scafctl-specific name completion no generic YAML tool can offer,
drawn from the document’s reference index: after
_.(CEL) or._.(template) it offers resolver names (and__actions./.__actions.offers action names); acall:value offers call names; arslvr:value offers resolver names; and adependsOn:list item offers resolver names (in a resolver) or action names (in an action). An actiondependsOnis scoped to the action’s own workflow section – anactionsentry offers onlyactionsnames and afinallyentry onlyfinallynames – matching what validation allows, so a suggestion never produces a cross-section error. Only defined symbols are suggested.
Completion is triggered on ., :, and space. Structural key, enum-value, and
CEL/template function completion work mid-edit even while the document does not
yet parse (the container is inferred from indentation). Symbol completion is
sourced from the reference index, so it becomes available once the solution model
builds; while the document does not yet parse it simply offers nothing rather
than erroring.
Try: on a new line under a resolver, type de and accept description; or in a
value: expression type _. to pick a resolver by name.
Signature help#
While typing a call’s arguments the server shows the declared parameters and highlights the one at the cursor:
- CEL function (
arrays.groupBy(...)) – the function’s signature, with the active parameter tracking the comma-separated position. - Go-template function (
{{ greet ... }}) – an author-defined helper’s declared parameters (spec.functions[name].params), tracked by space-separated position; a built-in template function shows its name and description. - Call args – inside a call invocation’s
args:block, the invoked call’s declared arguments (spec.calls[name].args), highlighting the argument on the current line.
Signature help is triggered on ( and space, and re-triggered on , so the
highlighted parameter follows the cursor as you move between CEL arguments.
Try: type arrays.groupBy( in a value: expression – the parameter list pops
up and the active parameter advances as you type past each comma.
Quick fixes#
textDocument/codeAction turns certain lint diagnostics into one-click fixes (the
editor’s lightbulb), reusing the same fix logic (lint.QuickFixFor) so the
applied edit always matches what the linter would recommend:
- deprecated field – replaces the deprecated
onErrorfield with its successorcontinueOnError, translating the value (onError: continuebecomescontinueOnError: true, andonError: failbecomescontinueOnError: false). - redundant dependsOn – removes the redundant
dependsOnentry (or the wholedependsOn:block when every listed dependency is already inferred from value references). - unused resolver – removes the entire unreferenced resolver block.
For example, given a resolve source with the deprecated field:
resolve:
with:
- provider: parameter
onError: continue # deprecated-field diagnostic here
inputs:
value: devinvoking the quick fix rewrites just that line to continueOnError: true, leaving
surrounding formatting and comments untouched.
Try: place the cursor on a line with a fixable diagnostic and trigger Quick Fix (Cmd/Ctrl+.); accept the suggested edit.
Code lens#
Above each resolver and action definition the server shows inline, clickable
code lenses (textDocument/codeLens):
- N references – the count of references to the symbol, computed from the
reference index; clicking peeks the references. The count and locations are
filled lazily via
codeLens/resolve, so listing lenses stays cheap. - Run – runs the resolver/action; the extension spawns
scafctl run resolver <name>/run action <name>against the file in a terminal. - Preview output – shows what the symbol produces without committing to side
effects; for a resolver this runs it (resolvers are side-effect-free), and for
an action it adds
--dry-runso the action is validated and its plan shown without executing.
The server emits the lenses and the exact CLI arguments; the extension executes Run/Preview by spawning the CLI, so the behavior matches running the command yourself. Unsaved edits are flushed to disk first, so the run reflects what you see in the editor.
Try: click Run above a resolver – a terminal opens and runs it; click N references to jump through its uses.
Non-goals#
Some editor capabilities are deliberately not provided by scafctl lsp,
because a solution file is YAML and the ecosystem already handles them well.
Layering on top of the built-in YAML support (rather than owning the buffer) is
the same approach GitHub Actions and other YAML-based tools take:
- Formatting (
textDocument/formatting) – left to the Red Hat YAML extension or Prettier. The server never rewrites whitespace/layout wholesale; its edits (rename, quick fixes) are surgical and preserve surrounding formatting. - Folding ranges – the editor’s generic YAML support already folds by indentation.
- Semantic tokens – the YAML grammar already colors the file; scafctl’s value is semantic navigation and validation, not re-coloring tokens.
- Inlay hints – omitted to avoid visual noise; the same information (types, parameters, descriptions) is available on demand via hover and signature help.
These are intentional: keeping them out preserves the other extensions’ features on the same file and keeps the server focused on what only scafctl can know.
Generative actions & commands#
Beyond quick fixes, textDocument/codeAction also offers generative and
refactor actions. Two of them are backed by the server’s
workspace/executeCommand infrastructure: the code action carries a command
(not an inline edit); the editor collects a little input, then invokes the
server command, which computes the source-preserving edit and applies it via a
workspace/applyEdit request.
Create missing resolver (quick fix) – offered on an
unknown-resolver-referencediagnostic (e.g. a_.doesNotExistreference with no matching resolver). It inserts a minimal stub resolver of that name underspec.resolversas a direct edit (no prompt):doesNotExist: resolve: with: - provider: static inputs: value: ""Extract to call… (refactor.extract) – offered when the cursor is inside a direct provider step (a
resolve/transform/validatewith[i]step that is not already acall:). The editor prompts for a new call name, then runs thescafctl.applyExtractCallserver command, which delegates to therefactor.ExtractCallengine to hoist the step into a reusablespec.callsdefinition and rewrite the step tocall: <name>.Add resolver… (source) – always available on a parsed document. The editor prompts for a resolver name and a provider (quick-pick), then runs the
scafctl.applyAddResolverserver command, which inserts a stub resolver underspec.resolvers.
The server advertises the two server-side commands
(scafctl.applyExtractCall, scafctl.applyAddResolver) in its
executeCommandProvider capability. The editor extension registers the two
client-side prompt commands (scafctl.extractToCall, scafctl.addResolver)
that the refactor/source code actions reference. The split keeps user
interaction (prompts, quick-picks) in the editor while all edit computation
stays in the server.
Trying it by hand#
You normally never run the server directly, but you can confirm it starts:
scafctl lsp --helpAn LSP client drives it with framed JSON-RPC messages (initialize, then
textDocument/didOpen, didChange, didClose, and requests like
textDocument/hover or textDocument/completion). On didOpen/didChange the
server responds with a textDocument/publishDiagnostics notification containing
any lint findings for that document.
Configuring an editor#
The exact steps depend on the editor’s LSP client, but the shape is always the same:
Point the client at the
scafctl lspcommand (the server process). Clients that pass a transport flag by convention can usescafctl lsp --stdio; it is accepted as a no-op since stdio is the only transport.Attach it to the solution files scafctl recognizes. To avoid hardcoding a file list that drifts from CLI discovery, ask the binary which files it auto-discovers:
scafctl lsp document-selectors -o jsonThis reports the recognized file names partitioned by editor language –
solution.{yaml,yml,json},<binary>.{yaml,yml,json},taskfile.{yaml,yml}, andactions.{yaml,yml}– along with the effective binary name. JSON solutions are listed separately so the client attaches them as JSON (not YAML) documents.Open a
solution.yaml(orsolution.json,taskfile.yaml, …) – diagnostics appear inline and the other features activate as you edit.
The bundled VS Code extension does this automatically: it queries
scafctl lsp document-selectors at startup and builds its document selector from
the result, so it always matches CLI auto-discovery – including any embedder
binary name. Users can add globs for non-standard file names via the
scafctl.solutionFilePatterns setting.
Because the server reuses the CLI’s lint engine, schema, and reference index, no
separate configuration is needed to keep the editor and CLI consistent.
What’s next#
The server covers resolvers, actions, functions, and calls across diagnostics, navigation, rename, outline, hover, completion (keys, enum values, functions, and symbols), signature help, quick fixes, and code lens. A candidate future addition – generative code actions (e.g. “extract to call”) – would build on the same reference index and cursor resolver; the deliberate exclusions are recorded in Non-goals .
See also#
- Linting Tutorial – the diagnostics engine behind the server
- Refactoring Tutorial – the reference index and rename engine the server reuses