A Language Server Protocol (LSP) implementation for CFML / ColdFusion, written in Go.
Uses tree-sitter-cfml.
make buildOr manually:
go build -trimpath -ldflags="-s -w" -o cfmleditor-lsp ./cmd/cfmleditor-lspThe Go toolchain version is pinned in go.mod, and CGO is required (the
tree-sitter grammar is C).
A go.work is the normal setup for anyone working on the grammar and the server
together, and it is gitignored — so nothing in the repo updates it for you. It
carries its own go directive, and the build refuses to start when that is
older than go.mod's:
go: module . listed in go.work file requires go >= 1.27.1, but go.work lists go 1.26.8
Match it to the version in go.mod whenever that is bumped:
go work usegopls type-checks with the go/types of the Go release it was built with, not
the one on your PATH. A gopls built with an older Go rejects syntax that
release did not have, so after a go.mod bump your editor fills with errors on
code that builds and lints cleanly. Go 1.27 added new(expr) and allowed any
valid field selector as a struct literal key, and both are used here, so a gopls
built with 1.26 reports roughly a hundred and thirty phantom errors — mostly
unknown field X in struct literal.
Check what yours was built with, and rebuild it against the pinned toolchain if it is behind:
go version -m $(command -v gopls) | head -1
GOTOOLCHAIN=go1.27.1 go install golang.org/x/tools/gopls@latestEditors that manage their own copy need pointing at the rebuilt one, or their
cached binary removed so it is fetched again — Zed keeps its under
~/Library/Application Support/Zed/languages/gopls/, and the filename records
the Go it was built with.
This is the same trap make lint documents at length for golangci-lint, which
is why the Makefile builds that from source under this module's own toolchain.
gopls cannot be pinned the same way, because the build never invokes it.
The server communicates over stdio using JSON-RPC 2.0 with LSP headers:
./cfmleditor-lspConfigure your editor to launch this binary as an LSP server for .cfm, .cfc, .cfml, and .cfs files.
Place a .cfmleditor.json file in your project root to enable daemon mode and configure workspace indexing.
The same settings can also be supplied by your editor as LSP initializationOptions, which is useful when you would rather not add a file to the project. .cfmleditor.json takes priority: it wins on every key it sets, and editor settings fill in the rest. See Editor settings below.
{
"workspaceName": "myproject",
"workspacePaths": [".", "../shared-lib"],
"mappings": {
"models": "./src/models"
},
"componentResolvers": [
{
"match": "getService(\"$1\")",
"resolve": "packages.$1.service",
"prefix": "getService"
}
]
}| Field | Required | Description |
|---|---|---|
workspaceName |
Yes | Unique project name. Used to derive the daemon socket path so multiple projects don't collide. |
workspacePaths |
No | Relative paths to folders the LSP should treat as workspace roots. Resolved relative to the config file location. |
workspaceIndexGlobs |
No | Glob patterns to filter which .cfc files are indexed. |
mappings |
No | Component path mappings. Keys are the first segment of a dot-path, values are directory paths (absolute or relative to config). |
componentResolvers |
No | Custom patterns for resolving method calls to component paths. See below. |
formatting |
No | Formatter configuration object. See below. |
completions |
No | tagSnippets, functionSnippets, globalFunctionResolution. All three default to true; set the block only to turn one off. |
references |
No | textDocument/references support, off by default. See below. |
features |
No | Per-capability switches. documentHighlight, watchedFiles and rangeFormatting default to true; folding defaults to false and is opt-in. See below. |
debug |
No | Enable debug logging (zap.NewDevelopment). Outputs verbose logs to stderr. |
Mappings let you resolve component dot-paths that use a virtual root. For example, with "models": "./src/models", the dot-path models.User resolves to ./src/models/User.cfc.
Component resolvers teach the LSP how to resolve custom factory patterns to specific CFCs. This enables goto-definition and dot-completion for variables assigned from those patterns.
"componentResolvers": [
{
"match": "getService(\"$1\")",
"resolve": "packages.$1.service",
"prefix": "getService"
},
{
"match": "_parent",
"resolve": "packages.tass.core.kernel2",
"prefix": "_parent"
}
]| Field | Required | Description |
|---|---|---|
match |
Yes | Pattern to match against the RHS of an assignment. Use $1 as a capture placeholder. Without $1, acts as an exact variable name match. |
resolve |
Yes | Component dot-path or file path template. $1 is replaced with the captured value. File paths (with / or .cfc) are normalised to dot-paths. |
prefix |
Yes | Fast-check string. Lines without this prefix are skipped entirely — avoids expensive matching on every line. Pipe-delimit multiple alternatives (e.g. "createModel|buildModel") to share one match/resolve pair across call-site shapes that don't start with a common substring. |
anchored |
No (default false) |
Require prefix at the start of the expression rather than anywhere inside it. See below. |
noFollow |
No (default false) |
Accept a call through this resolver without checking that the method exists on the resolved component. Use it for dynamic factories, or Java objects whose stubs are incomplete. |
The match is case-insensitive and works regardless of qualifiers before it. For example, getService("$1") matches all of:
getService("timetable")_parent.getService("timetable")VARIABLES._parent.getService("timetable")
By default prefix is searched for anywhere in the expression, and matching starts from
wherever it is found. That is what makes the qualifier-insensitivity above work, but it also
means a resolver can claim an expression that merely contains its prefix:
{"prefix": "document", "match": "document", "resolve": "app.document"}also fires on the unrelated variabledomobject_document.- A deliberately broad catch-all such as
{"prefix": "get", "match": "get$1()", "resolve": "packages.tass.${1:lower}"}, written for a family of bare factory calls, also fires on thegetDirectContent()at the end ofVARIABLES._document.getDirectContent()— resolving it topackages.tass.directcontent.
In both cases the shortened expression matches the pattern exactly, so the wrong component is produced confidently rather than the resolver simply declining.
Setting "anchored": true requires the prefix at position 0, so the resolver only claims
expressions that genuinely start with it. Both examples above stop firing, while
getPageTools() — the bare factory call the catch-all was written for — still resolves.
Anchoring also makes the order of pipe-delimited alternatives irrelevant, since every
alternative that matches matches at the same position.
Anchoring is off by default because a resolver aimed at a call usually does want to match
through a receiver (VARIABLES._parent.getService("x")). Reach for it when a resolver is aimed
at a variable name, or when a broad catch-all is producing wrong answers — cfmleditor-lsp explain will name the resolver that fired.
The formatting object controls the built-in formatter invoked via textDocument/formatting.
"formatting": {
"enabled": true,
"selfCloseTags": true,
"whitespaceOnly": true,
"queryFormat": false,
"lowercaseTags": true,
"lowercaseAttributes": true,
"doubleQuoteAttributes": true,
"queryUppercaseKeywords": true,
"scopeCase": "leave",
"commaPosition": "after",
"queryCommaPosition": "preserve",
"lineWidth": 100,
"attrBreakThreshold": 4,
"indentWidth": 4
}| Field | Default | Description |
|---|---|---|
enabled |
false |
Enable the formatter. When false, formatting requests are ignored. Omitting it leaves whatever the editor's settings said, rather than switching the formatter off. |
selfCloseTags |
true |
Convert void/implicit-end HTML tags to self-closing form (e.g. <br> → <br />). |
whitespaceOnly |
true |
Reject formatting results that change non-whitespace content (safety guard). |
queryFormat |
false |
Format <cfquery> content (SQL re-indentation, keyword casing). When false, query content is emitted verbatim. |
lowercaseTags |
true |
Lowercase CF tag names (e.g. <CFOUTPUT> → <cfoutput>). When false, each tag keeps the casing it was written with, opening and closing halves independently. |
lowercaseAttributes |
true |
Lowercase attribute names. |
doubleQuoteAttributes |
true |
Normalize attribute values to double quotes. |
queryUppercaseKeywords |
true |
Uppercase SQL keywords inside <cfquery> blocks. |
parenSpacing |
(unset) | Padding inside parentheses. "pad" gives if ( a ) and foo( 1, 2 ); "tight" gives if (a) and foo(1, 2). Unset keeps the existing behaviour, which pads conditions and grouping but not argument lists — set it to get one rule in both places. |
braceStyle |
"same-line" |
Where a block's opening brace goes. "same-line" (K&R) keeps function f() {; "next-line" (Allman) puts the brace alone on the line below, and moves else, catch and finally onto their own lines so their braces line up too. A { … } block that is a statement in its own right is left alone — there is no header for its brace to go under. |
blankLinesInBlocks |
true |
Pad a block's body with a blank line after the opening brace and before the closing one. False gives compact blocks, and collapses an empty body from three lines to two. |
switchCaseIndent |
false |
Indent case and default labels one level inside the switch, with their statements one further in. False keeps the label at the switch keyword's own column. |
scopeCase |
"leave" |
Case for CFML scope names. Values: "upper", "lower", "leave". |
commaPosition |
"after" |
Comma placement in multi-line argument lists. Values: "after" (trailing), "before" (leading). |
queryCommaPosition |
"preserve" |
Comma placement in SQL SELECT lists. Values: "preserve" (keep original position), "after" (trailing), "before" (leading). |
lineWidth |
100 |
Soft column limit — attributes expand to separate lines when a tag exceeds this width. |
paramBreakThreshold |
0 |
Number of parameters above which a function declaration's parameter list is expanded onto separate lines. 0 expands every list that has parameters, which is what the formatter has always done; raise it to keep short signatures on one line. A list at or below the threshold still expands when it would run past lineWidth, or when it holds a comment or a trailing comma. |
attrBreakThreshold |
4 |
Number of attributes above which they are always expanded onto separate lines. |
indentWidth |
4 |
Spaces per indentation level. Overridden by editor tabSize when provided. |
debug |
false |
Enable formatter debug checks. |
Note: useTabs and tabSize are taken from the editor's formatting options (sent with each formatting request), not from this config.
The editor's insertFinalNewline and trimFinalNewlines (LSP 3.15) are honoured too. The
formatter rebuilds the document rather than editing it, so left to itself it always ends its
output with exactly one newline — and VS Code's defaults for both of those settings are false,
which would mean adding a final newline the editor said not to add and dropping trailing blank
lines it said to keep. A client that sends neither option gets that single trailing newline, as
before.
trimTrailingWhitespace is deliberately not honoured. For the same reason — output is
rebuilt from the syntax tree, not patched — the formatter has no trailing whitespace to keep, so
there is nothing it could honestly do with false short of declining to format.
Most capabilities the server adds are on by default and each can be switched off
on its own, for when one misbehaves on a real workspace and the alternative is
downgrading the binary. folding is the exception: it is off unless you ask
for it.
{
"features": {
"documentHighlight": true,
"folding": false,
"watchedFiles": true,
"rangeFormatting": true
}
}| Key | Default | Controls |
|---|---|---|
documentHighlight |
on | Shading the other occurrences of the identifier under the cursor. |
folding |
off | Syntax-aware folding ranges. With it off the editor folds by indentation, as it did before the feature existed. |
watchedFiles |
on | Re-indexing files changed outside the editor. With it off the index reflects startup plus whatever you have had open, and cfmleditor.reindex is the way to refresh it. |
rangeFormatting |
on | "Format Selection". Switching it off leaves whole-document formatting and format-on-save working. |
folding is opt-in because it is the most expensive request here to answer. A
script-syntax component reaches the CFML grammar as one opaque region, so
folding it means parsing the whole component body with the CFScript grammar on
every request — a few milliseconds on a large component, and there is no way to
answer it more cheaply without holding a parse tree per open document. Turn it
on with {"features": {"folding": true}} if you want it.
Set only the keys you want to change — the block is merged key by key, so naming
one leaves the rest alone, and an editor's initializationOptions and a project's
.cfmleditor.json can each set different ones.
Switching one off un-advertises it rather than leaving the server to decline the request, so the editor falls back to its own behaviour instead of offering a command that returns nothing.
rangeFormatting sits under formatting.enabled rather than replacing it:
formatting has to be on at all for either kind to run. It has its own switch
because it is the only one of the four that writes to your buffer, and it shares
all its machinery with format-on-save — without a separate switch, stopping it
would mean giving up format-on-save too.
linting and references are the same kind of switch and keep their own
top-level keys, references additionally defaulting to off.
CFLint diagnostics are off by default and enabled per workspace. The binary is
downloaded from the cfmleditor/CFLint releases on first use, unless a cflint
is already on PATH, which wins:
{
"linting": { "enabled": true, "minSeverity": "WARNING" }
}minSeverity is the least severe CFLint level still reported, named on CFLint's
own scale — FATAL, CRITICAL, ERROR, WARNING, CAUTION, INFO,
COSMETIC, in that order. Anything below it is dropped rather than merely made
quiet. Unset — the default — reports everything, and an unrecognised name is
ignored with a warning in the log rather than silently filtering nothing.
It is worth setting because of how severities are mapped. CFLint's seven levels have to fold onto the LSP's four, and every one of them lands on Error or Warning: an editor shows neither Hint nor Information by default — VS Code draws a Hint as a faint underline and keeps it out of the Problems panel, and hides Information unless "Show Infos" is ticked — so a level mapped to either would be published, logged, and then invisible, which is indistinguishable from a diagnostic that was never produced.
The cost of that is that advisory rules (OUTPUT_ATTR, IMPLICIT_SCOPE,
ARG_VAR_MIXED) arrive as loud as real ones. "minSeverity": "WARNING" is the
way back to only the rules worth acting on, and it is a filter on CFLint's scale
rather than on the mapped severity, so it can still tell an INFO from a
WARNING after the two have folded together.
| Level | LSP severity |
|---|---|
FATAL, CRITICAL, ERROR |
Error |
WARNING, CAUTION |
Warning |
INFO, COSMETIC |
Warning |
| Anything else | Warning, and never filtered by minSeverity |
textDocument/references — the editor's "Find All References" — is off by default and enabled per workspace:
{
"references": { "enabled": true }
}The capability is advertised to the editor only when it is on, so a workspace that has not opted in does not see the command at all.
It is opt-in because of what one request costs. Answering it walks and parses every CFML file under the workspace roots, the same scan the refs CLI and the cfmleditor.findRefs command already do; there is no incremental index of call sites to answer from. On a few hundred files that is imperceptible, and on a few thousand it is a noticeable pause during which the server is busy. Whether that trade is worth making by default is the thing the flag exists to find out.
What it answers depends on what the cursor is on:
| Cursor on | Returns |
|---|---|
| A function name, declared or called | Every call site that resolves to that function, with calls to same-named functions on other components excluded |
A component dot-path (new models.UserDAO(), extends, <cfinvoke component>) |
Every place that path is written |
The search is scoped by the file that declares the function, which is resolved first by the same rules go-to-definition uses. That is what makes the request work with the cursor on a call site rather than only on the declaration.
includeDeclaration is honoured. A function the server cannot pin to a single declaration — several same-named functions across the workspace, none of them in the current file — falls back to scoping the search by the requesting document rather than picking one of them, so the answer is narrow rather than wrong.
Every field above can be sent as LSP initializationOptions instead of, or alongside, .cfmleditor.json. The payload has exactly the same shape as the file.
In Zed, via settings.json:
{
"lsp": {
"cfmleditor-lsp": {
"initialization_options": {
"linting": { "enabled": true },
"mappings": { "models": "./src/models" }
}
}
}
}In VS Code and most other clients the equivalent key is initializationOptions.
Precedence, when both are present:
| Result | |
|---|---|
Key set in .cfmleditor.json |
The file's value wins |
| Key set only in editor settings | The editor's value applies |
formatting set on both |
Merged key by key — the file wins on the keys it names, the editor's other keys stand |
mappings, beanPaths, and other maps |
Merged per key; the file wins on conflicts |
componentResolvers, propertyResolvers |
Both apply, with the file's entries tried first |
Relative paths resolve against the directory of whichever source declared them — the config file's own directory, or the first workspace folder for editor settings.
Two caveats:
- Settings are read once, at
initialize. Changing them requires restarting the language server. debugis ignored here, because the logger is constructed before the client connects. Use.cfmleditor.jsonfor that one.
When .cfmleditor.json is found, the server starts in daemon mode. The search walks upwards from the current directory to the filesystem root, and the nearest config wins:
- The first editor session becomes the daemon, listening on a Unix socket and serving LSP over stdio.
- Subsequent sessions connect to the existing daemon via the socket, sharing a single index.
- The daemon shuts down automatically when all editor sessions disconnect.
Without a config file the server runs in standalone mode — a single session with its own index. Standalone sessions look for a config the same way, walking upwards from each workspace folder the editor reports, so the same file is picked up in either mode; what they do not do is join a daemon.
That is deliberate. The socket is derived from workspaceName, so an index is only ever shared between sessions that named the same project. With no config there is no name to key on, and the alternative — falling back to the working directory — is not one: it groups whatever happens to share a folder name, and an editor that starts the server without setting a working directory (the IntelliJ plugin does not) gives every project on the machine the same one. A shared index means one project's symbols answering another's go-to-definition and workspace-symbol queries. Add a .cfmleditor.json with a workspaceName to get the sharing.
- If
workspaceIndexGlobsis set, only.cfcfiles matching those patterns are indexed. - If only
workspacePathsis set, all.cfcfiles under those folders are indexed. - If neither is set, the LSP falls back to indexing workspace folders reported by the editor.
- Initialize / Shutdown / Exit
- textDocument/didOpen
- textDocument/didChange (full sync)
- textDocument/didClose
make link builds this working tree and symlinks it onto your PATH:
make link # symlink into `go env GOPATH`/bin
make link-status # show what the link points at, and what PATH actually resolves
make unlink # remove itThe link points at target/release/cfmleditor-lsp, so a later make build takes
effect without re-linking.
This is how the zed-cfml extension picks
up a local build. It resolves its server in three steps: a path it has already
cached, then a cfmleditor-lsp found on PATH, and only then a download of a
GitHub release. A symlink on PATH wins at the second step, so no release is
downloaded. Restart the editor after linking or unlinking — the resolved path is
cached for the life of the session.
Symlinking into the extension's own directory instead would not survive: that directory is named after the release version, and the extension removes the versions it is not using.
If go env GOPATH/bin is not on your PATH, point the link somewhere that is:
make link LINK_DIR=$HOME/.local/bin
make unlink LINK_DIR=$HOME/.local/bin # same LINK_DIR to remove itmake link refuses to overwrite a real file at that path — normally a make install copy — rather than silently replacing it, and warns when the link it
just made is shadowed by another cfmleditor-lsp earlier on PATH.
| Command | Description |
|---|---|
make build |
Update grammar, generate docs, and build the binary. |
make test |
Run all tests. |
make lint |
Run golangci-lint. Use an official v2 release binary — one built with go install is compiled against golangci-lint's own (older) Go toolchain and then refuses this repo's newer go.mod target: "the Go language version used to build golangci-lint is lower than the targeted Go version". |
make lint-fix |
Run golangci-lint with auto-fix. |
make vuln |
Scan dependencies and the stdlib for known vulnerabilities (govulncheck). |
make update-grammar |
Regenerate docs and tree-sitter grammar, clear Go build cache. |
make release <version> |
Full release: validate, build, test, lint, update changelog, commit, tag, push. |
make install |
Build and copy binary to go env GOPATH/bin. |
make link |
Build, then symlink the binary onto PATH for local editor use. Override the directory with LINK_DIR=<dir>. |
make unlink |
Remove that symlink. |
make link-status |
Show the link, the build, and what PATH resolves cfmleditor-lsp to. |
make clean |
Remove build artifacts. |