Language servers
deco speaks the Language Server Protocol directly: Content-Length framing,
JSON-RPC 2.0, the full lifecycle, and position-encoding negotiation. It does not
route through an extension host, so a server works whether or not the matching
VS Code extension exists.
Diagnostics
Diagnostics arrive by textDocument/publishDiagnostics and replace the previous
set for that document, which is what the protocol means: a server publishes the
complete list each time, and an empty list is how it says the problems are fixed.
The status bar tallies errors and warnings; F8 and shift+F8 walk them.
| Key | Command |
|---|---|
F8 |
editor.action.marker.next |
shift+F8 |
editor.action.marker.prev |
F8 visits problems in file order, not publication order — servers emit in
whatever order analysis finished, and “next” has to mean next in the file or the
cursor jumps around unpredictably. It wraps, and it reports what it landed on.
Positions are clamped, because a diagnostic can outlive the text it describes: you
may have deleted the offending lines before the server caught up.
Information and hints are folded into neither tally. They are not problems you have to act on, and a status bar has room for the two that are.
Hover
ctrl+k ctrl+i asks for hover text and draws it below the caret, or above when
there is no room — a box hanging off the bottom of the terminal is worse than one
covering the line above. The status bar is never covered: it is where the editor
reports everything else, including why a hover might be wrong. escape dismisses
it, and so does moving the cursor, since a hover describing where the cursor was
is worse than none.
The protocol’s contents field has four different shapes across protocol
versions and servers — a string, a MarkedString, an array of either, or a
MarkupContent — and deco reads all four, flattening to plain lines.
Completion
| Key | Command |
|---|---|
ctrl+space |
editor.action.triggerSuggest |
down / up |
selectNextSuggestion / selectPrevSuggestion |
tab / enter |
acceptSelectedSuggestion |
escape |
hideSuggestWidget |
The list opens on ctrl+space and also on a trigger character the server asked
for — . or ::. Typing narrows it in place and the same keystroke goes into the
document, so the list and the file always agree about what has been typed; a
backspace widens it again. Ranking prefers a prefix match, then a
case-insensitive prefix, then a subsequence, and the selection is the best match
for what has been typed, re-chosen as the list narrows and widens — which is what
editor.suggestSelection: "first", VS Code’s default, does. deco does not read
that setting, so the other values are not available.
A server’s preselect still decides the row the list opens on: a server that
knows the likely answer puts it there, and it is answering the query as it stood
when the list was asked for.
The marker in the left column is the item’s kind: f function, v value, t
type, m module, k keyword, s snippet, · anything else.
Snippets are inserted without their placeholders. deco advertises
snippetSupport: false and several servers send them anyway, so foo(${1:arg})
becomes foo(arg) and the status bar says so. Inserting the placeholder syntax
literally would be worse; there are no tab stops to jump between yet.
References
shift+f12 lists everything that refers to the symbol under the cursor, and
enter opens that file at that line. It is the same list a project-wide search
produces — the question is the same one, which of these places do you want to be
— so it filters as you type in the same way.
| Key | Command |
|---|---|
shift+f12 |
editor.action.goToReferences |
Each row is path:line: the line's text, with the path shortened against the
workspace root; a location outside the workspace keeps its whole path, because
there the directory is the informative part. The declaration is included, because
“find all references” that omits the definition is a surprising answer and VS Code
includes it too.
The line’s text comes from the open document when the location is in it, and
from disk otherwise — the two differ exactly when there are unsaved changes, and
the list has to describe what you are looking at. Locations in a scheme deco
cannot open (jdt:, untitled:) are left out rather than listed as rows that do
nothing.
Go to symbol
ctrl+shift+o asks for the names a document declares and offers them as the same
list references and project search use — enter goes to one. The picker itself is
documented with the other prompts, under
Go to symbol; what belongs here is the protocol.
| Key | Command |
|---|---|
ctrl+shift+o |
workbench.action.gotoSymbol |
textDocument/documentSymbol has two result shapes, and which one arrives
depends on the server:
// DocumentSymbol[]: a tree, with the nesting the file has
[{ "name": "Counter", "kind": 23, "range": {…}, "selectionRange": {…},
"children": [{ "name": "bump", "kind": 6, … }] }]
// SymbolInformation[]: flat, each with a whole location and a container name
[{ "name": "bump", "kind": 6, "containerName": "Counter",
"location": { "uri": "file:///…", "range": {…} } }]
Both are read, and both flatten to one list in document order — a parent before
its children — because that is the order a reader of the file expects. A nested
symbol keeps its whole path, so a method three levels down reads as
outer.middle.leaf.
A symbol is positioned on selectionRange, which is the identifier, and not on
range, which covers the whole definition and would land the cursor on a doc
comment. SymbolInformation’s location.uri is deliberately ignored: the
request named one document, so a server answering about another is out of spec, and
trusting it would let a symbol list navigate somewhere unrelated.
A symbol with no name or no position is dropped — there would be nothing to pick
or nowhere to go — but an unrecognised SymbolKind is not: that is a newer
specification than this client, and the name is the useful part.
Accepting a symbol goes through the same open-a-file-at-a-position path a search result does. For the document already on screen that is a tab switch onto itself, so unsaved changes survive; and because each row carries the path that was asked about, an answer that arrives after you switched tabs still navigates the right file.
Semantic tokens
A server’s semantic tokens say what a name is — a type told from a variable by
its declaration, a shadowed binding, a macro apart from a function — which is
precisely what the lexer cannot know. deco asks for the whole
document’s tokens when the file opens and again after each edit, and colours what
comes back from the theme’s semanticTokenColors.
In those frames LIMIT is teal to the lexer, which can only see that it is
capitalised, and blue to the server, which knows it is a read-only binding.
The lexer is not replaced. A token type the theme has no rule for falls back to the lexer’s colour, and so does every character no token covers — punctuation, whitespace, comments most servers do not classify. The result is a document that is fully coloured whether or not a server is running, and more precisely coloured when one is.
| Setting | Effect |
|---|---|
editor.semanticHighlighting.enabled: true |
Always draw them |
editor.semanticHighlighting.enabled: false |
Never draw them |
absent, or "configuredByTheme" |
The theme’s own semanticHighlighting flag decides |
Deferring to the theme is VS Code’s default and the right one: a theme written without semantic rules looks worse with them applied, because the few types it does resolve overrule a lexer that was colouring everything consistently.
The wire format is a flat list of integers, five per token, each token’s position
stated relative to the one before it — and deltaStart is relative to the
previous token’s column only when the two share a line, otherwise it is an
absolute column. Tokens naming a type outside the legend the server announced at
initialisation are dropped, but still advance the position, since a following
token’s coordinates are relative to the one deco could not name.
A classification describes the text it was computed from, so an edit discards it rather than keeping it: a token list applied to shifted text colours the wrong words, which is worse than the lexer alone for the moment the answer takes. Keypresses that only move the cursor send nothing and keep the tokens, and a request is not made while one is already outstanding.
The feature needs full document support and a non-empty legend. A server
offering only ranges or delta updates is treated as not offering the feature at
all, rather than half-colouring the file.
Formatting
ctrl+shift+i formats the document and ctrl+k ctrl+f the selection. Both keys
are gated on editorHasDocumentFormattingProvider, so they do not resolve at all
when the server cannot format — the key reports nothing rather than reporting a
failure.
The options sent are yours: editor.tabSize, editor.insertSpaces,
files.trimTrailingWhitespace and files.insertFinalNewline, resolved through the
same settings layering as everything else, including any [language] override.
A batch of edits is applied as one transaction, back to front against the pre-edit document, so it is one undo step and no edit lands in a position that a previous edit moved. Overlapping edits are refused rather than guessed at: the specification forbids them, so a server sending them is broken, and picking which to honour would corrupt the file silently.
Configuring a server
deco.lsp.servers is keyed by a server identifier, and each definition says
which languages it serves. It is deco’s own namespace rather than one of VS
Code’s, because in VS Code a server arrives inside an extension and there is no
equivalent setting.
{
"deco.lsp.enabled": true,
"deco.lsp.servers": {
"rust-analyzer": {
"languages": ["rust"],
"command": "rust-analyzer",
"args": [],
"initializationOptions": { "cargo": { "features": "all" } }
},
"pylsp": {
"languages": ["python"],
"command": "pylsp",
"args": ["--check-parent-process"]
}
}
}
A definition you supply wins over the built-in one for the same language, even though the two have different identifiers — otherwise configuring a server would silently lose to the default.
The command and its arguments are passed as a list and never as a shell string, so nothing in them is interpreted by a shell.
A server defined by workspace settings is refused by name. A cloned repository can otherwise run a program of its choosing the moment you open a file in it. The editor says which server it declined and why, and falls back to the next candidate for that language — so a repository cannot disable the feature by defining a server you then decline.
The same goes for one a remote defines. A remote session’s machine-settings.json
is written where anyone with an account on that machine can write it, and
choosing to connect somewhere is a decision about the machine, not a signature on
every file on it. So a definition arriving that way is confirmed like a
workspace’s — which is a nuisance exactly once, and is the difference between
connecting to a build box and running whatever it nominates. See
Settings that belong to the machine.
The refusal is recorded in the problem list, which deco --print-config prints
and F8 walks, whether or not another server started for that language. It also
reaches the status bar when nothing started, because with no server running there
is nothing else the row could be saying.
There is no “I trust this repository” to say once. VS Code has Workspace Trust; deco would have to remember the answer somewhere, and it does not write configuration files by design. So the way to run a repository’s own server is to copy the definition into your user settings, having read it — which is the step Workspace Trust makes it easy to skip.
Not built yet
Rename and code actions are parsed but not wired. Applying a rename means editing
files that are not open — possible now that there are tabs, but it needs a
WorkspaceEdit applied across several documents as one undoable action, which
does not exist yet. Those keys say the feature is not implemented rather than
pretending.
Changes are sent as full-document syncs; the incremental path exists in
deco-lsp but the editor does not yet track applied ranges. Semantic tokens are
whole-document for the same reason: textDocument/semanticTokens/full/delta needs
the previous result held and patched, and a full request after each edit is
correct without it.
Go-to-definition across files opens a new tab, or switches to the tab already holding the file — see Tabs. When a server returns several results they are offered as the same list references uses, rather than guessed between.