Help us shape the future of Go! Take the 2026 Go Developer Survey

cfmleditor-lsp

module
v0.3.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 26, 2026 License: MIT

README

cfmleditor-lsp

A Language Server Protocol (LSP) implementation for CFML / ColdFusion, written in Go.

Uses tree-sitter-cfml.

Build

make build

Or manually:

go build -trimpath -ldflags="-s -w" -o cfmleditor-lsp ./cmd/cfmleditor-lsp

The Go toolchain version is pinned in go.mod, and CGO is required (the tree-sitter grammar is C).

If you keep a go.work

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 use
Keep gopls on the same Go release

gopls 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@latest

Editors 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.

Run

The server communicates over stdio using JSON-RPC 2.0 with LSP headers:

./cfmleditor-lsp

Configure your editor to launch this binary as an LSP server for .cfm, .cfc, .cfml, and .cfs files.

Code map

cfmleditor-lsp graph builds a map of a whole project: every function and file, and the calls, instantiations, inheritance and includes between them. On an 11,700-file workspace that is around 60,000 nodes and 105,000 edges in roughly ten seconds.

cfmleditor-lsp graph .                                     # summary to the terminal
cfmleditor-lsp graph --format html --out map.html .         # interactive report
cfmleditor-lsp graph --level package --format dot . | dot -Tsvg > map.svg
cfmleditor-lsp graph --db .cfmleditor/codemap.db .          # save it, and cache it

Every declared function is in the map, whether or not anything calls it. Code nothing reaches is not dropped and not merged into the main graph: it becomes its own island, with its own root, which the HTML report draws as a separate tree and --detached lists on its own. A map that quietly omitted what it could not connect would describe a tidier codebase than the one on disk.

Levels
--level Nodes For
function (default) functions and files everything; files carry the relationships functions cannot
call functions, plus pages whose top-level code calls something a strict call graph: shortest paths, real cycles, functions nobody invokes
file one per file module-scale dependency structure
package one per directory the level that actually renders as a picture of a large project

function is deliberately a hybrid. Three of the four relationships a CFML codebase has are between files — a component extends a component, a page includes a page, new Foo() names a component it may never call a method on — so dropping file nodes drops all of that. Use call when you want only "what calls what".

Formats

text (default), json, jsonl, dot, mermaid, html.

json is the canonical artifact; everything else is a view of it. mermaid is capped on purpose — it renders in a browser and falls over in the low thousands of nodes, so a map big enough to need a cap should be collapsed to --level package first.

html is a single self-contained file with six views: hierarchical edge bundling (grouped by island, then directory), a force layout that gives each island its own centre so detached code sits apart rather than being pressed against the border, a dependency matrix that has no occlusion at any size, and an islands view of the disconnected pieces. Above ~1,200 nodes the force view renders to a canvas with a quadtree for hit-testing, so it stays interactive into the tens of thousands.

Two more exist because at tens of thousands of nodes none of those four can show the whole map. Drill-down starts at the top-level directories and expands one level per click, so what is drawn depends on what you opened rather than on the size of the map. Focus draws one node and everything within n hops, a ring per hop, and clicking any node re-centres. Neither is capped.

The report embeds its JavaScript — a 76KB D3 bundle of just the modules these views use, built from internal/codemap/assets/vendor and committed. No CDN, no network: the file opens the same on an air-gapped machine and still renders years later.

From the editor

cfmleditor.generateCodeMap builds a report from the running server, so nothing needs a CLI on a PATH:

// workspace/executeCommand — every field optional
{ "level": "call", "format": "html", "out": "reports/map.html",
  "under": "packages/tass", "live": true, "open": true }

Defaults are level: function, format: html, output .cfmleditor/codemap.html, and the codemap config block supplies the rest. cfmleditor.showCodeMapStats answers the cheap question — how much of this workspace the map resolves — without writing anything.

It reuses the server's index, which didChange and the watched-file handler already keep current, so it skips the index pass a cold CLI run pays for. It returns immediately and reports through window/showMessage, and it will not write outside the workspace.

Scoping

--under <path> narrows the map to a prefix and keeps the nodes just outside it that an edge crosses into, marked as boundary nodes. Cutting hard at the prefix instead removes every caller from elsewhere — which is exactly what you scoped the map down in order to see — and the package comes back looking uncalled rather than narrow. --under-strict does the hard cut if you want it.

A map collapsed with --level file or --level package has no function nodes in it, so searching one for a function name can only come back empty; the HTML report says so rather than showing nothing.

Framework routes

Convention-based routing is invisible to static analysis. A dispatcher reads a dotted route out of a URL or an HTML attribute, builds a component path and a method name from it and invokes them — nothing in the source names either, so every routed controller method looks uncalled and every view unreferenced.

routes in .cfmleditor.json describes the convention, and the LSP then follows it: route edges in the code map, ctrl-click to the controller method or the view from go-to-definition, and a document link on each resolvable route.

"routes": {
  // where routes are written — all four are optional
  "attributes":  ["data-view", "data-read", "data-process"],
  "queryParams": ["do"],                        // href="x.cfm?do=a.b.c"
  "properties":  ["read", "view", "process"],   // { view: "a.b.c" }
  "functions":   ["redirect", "setPrint"],      // any string argument

  // a trailing segment that says how a target is shown, not what it is
  "suffixes": ["iframe"],

  "aliases": { "ui.web": ["tassweb", "kiosk", "parentportal"] },
  // prefer a mapped path over one relative to the workspace root
  "controllers": [
    { "component": "tassweb.packages.tass.${1}-${2}", "method": "${3+:search}" },
    { "component": "tassweb.packages.tass.${2}",      "method": "${3+:search}" },
    { "component": "tassweb.packages.tass.${1}",      "method": "${2+:search}" }
  ],
  "views": [
    { "longestDir": true, "ext": [".cfm"] },
    { "longestDir": true, "root": "..", "ext": [".cfm"] }
  ]
}

Four syntaxes, because routes are not written one way. A URL parameter's value sits inside the enclosing href's quotes, so it runs to the next delimiter rather than to a quote; a function argument often carries a query string or fragment after the route, so it is cut at the first delimiter rather than rejected for holding one. JavaScript properties are the loosest — read and view are ordinary words — which is why a value must look like a route before it is resolved at all.

Every string argument of a listed function is read, not just the first. A route may be passed positionally, or as route="a.b.c", or as r="a.b.c", or one call down in redirect(buildRoute("a.b.c")) — the parameter name is the caller's business, and a scanner that had to be told it would need a config entry per function per codebase. Taking every string and letting the route grammar reject the rest costs an unresolvable route, which produces no edge; requiring the name costs every call that spells it differently.

${N} is one segment, ${N+} everything from N on, ${N-M} a span. A :concat suffix joins without the dots (dialog.custom.roll → dialogCustomRoll), :slash with them, :lower/:upper fold the case; the default joins with dots. A rule that reaches past the end of a route declines it rather than matching a truncated path.

Write a controller template through a mappings entry — tassweb.packages.tass.${1} rather than packages.tass.${1} — when one exists. A route names a component globally, so it is resolved against one base directory for the whole workspace rather than against the directory of the file it was written in; both spellings work when that base is the application itself, and only the mapped one keeps working for a route written in a sibling application's file.

:search enumerates start points, longest first. A route's segments do not say where the controller's name stops and the method's begins — the same shape is spelled studentMainStudent() on one controller and mainStudent() on another — so one rule covers both instead of one rule per start point. It is safe only because every candidate is still checked against the component's real methods.

Controller rules are tried in order and the method must exist, which is what makes the order safe: a component template built from the first segment matches enormous numbers of routes, so without the method check it would shadow every rule below it.

suffixes strips a trailing segment that says how a target is presented rather than what it is. TASS writes kiosk.lms.lms_grades.iframe for the route kiosk.lms.lms_grades loaded inside an iframe container. The route is tried as written first, so a suffix that is also a genuine trailing segment somewhere keeps resolving as it did; suffixes are stripped before aliases are applied, so an alias does not have to be written once per presentation.

Aliases can name several replacements. ui.web means "the product serving this page", and a view shared between products reaches whichever one is running — which cannot be known statically. All of them are returned, the code map marks the edge as a guess, and go-to-definition offers the choice.

A path template controls its own separators: a literal / between directories, and whatever join the segment reference asks for inside a name. So "webroot/${2}/${3+}" resolves tassweb.assessment.dialog.objectivegroup.setup to webroot/assessment/dialog.objectivegroup.setup.cfm — a directory called assessment holding a dotted file name, not a directory called dialog.

views either takes a path template or longestDir, which finds the longest leading run of segments that names a real directory and treats the rest as a dotted file name (ui.web.general.popup.lookup.filter is ui/web/general/popup.lookup.filter.cfm). No template can express that, because the split depends on what is on disk.

The same grammar covers FW/1 — { "component": "controllers.${1}", "method": "${2}" } with { "path": "views/${1}/${2}" } — which is the test that keeps it from being one framework's rules in disguise.

cfmleditor-lsp routes <dir> reports what was found and what it resolved to, and --unresolved groups what it could not so a missing rule is visible: one unresolved route is usually noise, forty sharing a prefix is a shape the config does not cover.

cfmleditor-lsp routes --unresolved .                        # to the terminal
cfmleditor-lsp routes --format md --out routes.md .          # a report to keep
cfmleditor-lsp routes --format json . | jq                   # for a script

The markdown report is the one to keep. It groups by shape — how many unresolved routes have a dialog segment, a popup, an action verb — because a whole row is usually one missing rule rather than one problem per route. It also separates the prefixes that resolve elsewhere from the ones that never do: the first means the controller is found and only the method name is underivable, the second means the config cannot locate the controller at all, and those are different fixes. Every route is cited with its file and line, so it can be committed beside the config or handed to whoever knows the framework.

The build reports the same share. A low one means the config describes a different convention from the one in use, and the route edges are worth correspondingly less.

Utility code

Some code is infrastructure: the logging, the PDF writer, the context accessor every request touches. It is genuinely the most-depended-on code in a workspace — on one, the entire top ten by fan-in was infrastructure — so it dominates every ranking and ties every part of the graph to every other.

Marking it is not excluding it. The counts, the rankings and the islands all keep counting it, and the HTML report gains a toggle:

"codemap": {
  "entry":   ["../prs"],                          // a runner invokes these
  "utility": ["packages/tass/core/context.cfc"],  // infrastructure
  "hideUtility": true                             // report opens with it off
}

--entry and --utility on the command line add to the config rather than replacing it, so a one-off question needs no config change and cannot silently drop the project's own settings. --hide-utility sets the toggle's starting position; the nodes are in the page either way, so a reader can always switch them back on and see what was set aside.

The store carries the marking, so the ranking is a query:

-- the most-depended-on application code, infrastructure set aside
SELECT id, in_degree FROM nodes
WHERE kind = 'function' AND utility = 0
ORDER BY in_degree DESC LIMIT 20;

-- whether a file is infrastructure, by how concentrated its fan-in is:
-- utility has few functions each called by many, application code the reverse
SELECT file, SUM(in_degree) AS fan_in, COUNT(*) AS fns,
       CAST(SUM(in_degree) AS REAL) / COUNT(*) AS per_fn, MAX(utility)
FROM nodes WHERE kind = 'function' GROUP BY file ORDER BY per_fn DESC;

That second query is how to choose the list in the first place. Total fan-in does not discriminate — a large application component accumulates plenty — but concentration does: on one workspace the infrastructure sat at 9 to 75 incoming dependencies per function while application code sat below 2.

Collapsing treats a package as utility only when all of it is — one utility file among twenty application ones is not something a reader can set aside.

Per-application configs, and code a runner invokes

A workspace is often several applications side by side, each with its own .cfmleditor.json — and each listing the others in workspacePaths, so any scan reads all of them. By default each file is now resolved under its own nearest config, because applying one application's componentResolvers to another's source does not fail loudly: it resolves nothing, and those files come out of the map with no edges. --one-config restores the old single-config behaviour.

--entry <glob> marks files as entry points by path. Some code is invoked by a runner that constructs its name, so nothing in the codebase names it and no static analysis can see the call — release-script directories, scheduled tasks, plugin folders. On one workspace 10,639 of 11,374 apparently-unreferenced functions were release scripts of exactly that kind:

cfmleditor-lsp graph --entry '../prs' --entry 'tasks/*' .

A bare directory name matches everything beneath it. Private methods are never marked, since a runner reaching in by a constructed name cannot reach one.

Calls from top-level page code count. A .cfm page is mostly code with no enclosing function, so its calls are attributed to the file node. That is not a fallback: on a real workspace, file-level callers account for 74,226 of 107,683 call sites — more than every function-level caller combined.

Database and cache

--db <file> writes the map to SQLite and reuses a per-file cache on the next run. The cache is keyed on each file's content hash and a fingerprint of every indexed component, because resolving a call reads the index built from every other .cfc — so editing any component invalidates the cache and the rebuild is a full one, while editing .cfm pages leaves it valid. On a large workspace that is roughly 15 seconds cold against 7 warm.

The schema is the contract, and sqlite3 is a supported way to use it:

-- the de-facto API: what the most code depends on
SELECT id, in_degree FROM nodes ORDER BY in_degree DESC LIMIT 20;

-- functions nothing reaches, in one package
SELECT id, file, line FROM nodes
WHERE kind = 'function' AND reachable = 0 AND file LIKE 'packages/tass/%';
MCP server

cfmleditor-lsp mcp --db <file> serves that map over the Model Context Protocol on stdio, so an assistant can ask about structure instead of grepping for it. It is read-only by construction: every tool is a query, and none writes a file or runs a command.

{
  "mcpServers": {
    "cfmleditor-codemap": {
      "command": "cfmleditor-lsp",
      "args": ["mcp", "--db", ".cfmleditor/codemap.db"]
    }
  }
}

Tools: search_symbols, get_symbol, get_callers, get_callees, find_path, list_islands, list_orphans, get_stats, and explain_call — which re-parses a file and traces, step by step, how a call site's receiver was typed and which componentResolver fired.

Read get_stats before trusting an empty caller list. The resolved/unresolved ratio is the map's confidence in itself: a call the resolver could not follow is an edge the map does not have, so on a workspace resolving around half its call sites, "nothing calls this" means rather less than it looks like it does. That is also why the unreferenced list is described as candidates rather than as dead code.

Not yet implemented

The VS Code extension stands its own language providers down while this server is running, so three capabilities it can answer are unavailable when the server is enabled:

  • textDocument/typeDefinition — go to the type of the symbol under the cursor rather than its declaration
  • Docblock completion — @param, @return and friends inside /** */ (would also need @ and * added to the completion trigger characters)
  • textDocument/documentColor — colour swatches and the picker

Configuration

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

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

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")
anchored

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 variable domobject_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 the getDirectContent() at the end of VARIABLES._document.getDirectContent() — resolving it to packages.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, or the "Explain call resolution on line N" code action, will name the resolver that fired.

dynamicIfMissing

A catch-all such as {"prefix": "get", "match": "get([A-Za-z]+)\\(\\)", "resolve": "app.${1:lower}"} is right for the factories it was written for and invents a path for every other getter: getTable() becomes app.table, and every call on the result is then reported as component 'app.table' does not exist. With "dynamicIfMissing": true, a component that resolver produces and that names no file is taken as a dynamic value instead, so its calls are accepted without a method check.

It is per resolver, and only that resolver's output is affected. A missing component named any other way — a getService("$1") resolver's, a literal createObject("component", ...) — is still reported, which is the point: those are findings. So is a missing method on a component the catch-all got right, since that component exists.

Formatting

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.

Features

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.
outputContextInterpolation on Reading #...# in a tag file's text only where ColdFusion evaluates it: inside <cfoutput>, <cfquery>, <cfmail> and output="true" functions, and in the attributes of CF and custom tags. Also reads a .cfm template with no CF tags as HTML rather than CFScript. Off, every pair of hashes in text is scanned, which finds calls in JavaScript, CSS and prose between two stray hashes. Unlike the others this changes what the parser reads, not what the server advertises.

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.

Linting

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
Known issues

knownIssues lists files of documented findings, such as known issues, TODOs, a baseline of unresolved calls or a project-wide CFLint run. The server publishes each entry as a diagnostic, so it appears in the editor's Problems panel beside everything else:

{
  "knownIssues": {
    "scope": "open",
    "severity": "warning",
    "files": [
      "docs/todo.txt",
      { "file": ".cfmleditor-cflint.txt", "severity": "warning", "scope": "workspace" }
    ]
  }
}

The block's scope and severity are what its files inherit. Every key is optional: with no block at all, or a block that sets neither, they are open and warning.

Key Meaning
scope open (the default) publishes a file's entries only while it is open, so a long list informs the file being worked on rather than filling the panel. workspace publishes every entry at startup, so the panel lists the whole project's
severity error, warning (the default), information or hint. Warning is the least severe level every editor's problems panel lists: Zed's lists only errors and warnings, so information entries show inline there but not in the panel, and VS Code's leaves out hints
files The files: each a path relative to the .cfmleditor.json, or an object

A file object:

Key Meaning
file The file, relative to the config
scope As the block's. Left out or "inherit", it is the block's
severity As the block's. Left out or "inherit", it is the block's. An entry's [severity CODE] tag overrides it
source The diagnostics' label: known issue by default, cflint for a generate: "cflint" file
generate unresolved or cflint: the file is a report the server writes. See below

The generated reports' default files, .cfmleditor-unresolved.txt and .cfmleditor-cflint.txt beside the .cfmleditor.json, are included without being listed, with scope and severity of "inherit", and show whenever they exist. List one only to set its own, as above; named by its default path, it keeps its generate kind.

Each file is watched, and editing one republishes its entries.

The format

One finding per line; blank lines and # comments are ignored:

packages/shop/service.cfc:120: svc.getOrder (method 'getOrder' not found in packages.shop.persist)
webroot/index.cfm:14:7: [warning CFQUERYPARAM_REQ] <cfquery> should use <cfqueryparam/>
webroot/index.cfm:30: [TODO] move this to the service

path:line[:col]: message. A message may start with a [CODE] or [severity CODE] tag, which becomes the diagnostic's code and overrides the file's severity; an untagged entry's code is known-issue. An entry in the unresolved format is underlined at the method it calls, and found again within 25 lines when an edit has moved it.

A path is relative to the directory holding the file, and is never absolute: the file is committed and read on other machines, where an absolute path would not name the same file, so one is ignored. A ../ path is kept. It reaches a sibling project the way workspacePaths do, and holds wherever the projects are checked out side by side.

Generated reports

Two reports are written by the server rather than by hand:

generate Written by Default file
unresolved cfmleditor.exportUnresolved, or cfmleditor-lsp unresolved --write <project> .cfmleditor-unresolved.txt
cflint cfmleditor.exportCFLint, or cfmleditor-lsp cflint --write <project> .cfmleditor-cflint.txt

Both are also offered as code actions on any CFML file, "Export unresolved calls report for the workspace" and "Export CFLint report for the workspace", which is how an editor that cannot run a server command itself reaches them: in Zed, cmd-. (ctrl-. elsewhere).

A command writes to every knownIssues entry marked with its kind or, when none is, to the implicit default file, and then republishes the entries straight away. Several files of one kind split the report by directory: each receives the findings under its own directory, the deepest when they nest, so a config over several projects can keep one report in each. A finding under none of them is left out and counted in the command's message. The header carries no date, so regenerating an unchanged project changes nothing a diff would show.

A cflint report's entries are labelled cflint and carry the rule ID, as CFLint's own diagnostics do. They give way file by file to CFLint on save: once a file is linted, its report entries are hidden, even when the run finds nothing, so an issue is never listed twice or outlives its fix. Regenerating the report shows its entries again for files that are not open. The export works with linting.enabled off, honours linting.minSeverity, and finds what a run on save finds for the same file.

To print a report instead of writing it, cfmleditor-lsp unresolved --known-issues <dir> writes the same format to stdout, relative to the config's directory or --relative-to <dir>. Findings outside it are left out, and counted, unless --include-workspace writes them as ../ paths.

Diagnostics from several sources

CFLint on save, the workspace parse scan and known issues all publish through one store, which keeps each source's diagnostics per file and always publishes a file with all of them. A publish replaces everything a server has sent for a file, so before the store, saving a file wiped its parse errors and a scan wiped its lint results. Closing a file clears its CFLint and parse diagnostics and its open-scope known issues, and keeps its workspace-scope ones.

References

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.

Editor settings

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.
  • debug is ignored here, because the logger is constructed before the client connects. Use .cfmleditor.json for that one.
Daemon mode

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:

  1. The first editor session becomes the daemon, listening on a Unix socket and serving LSP over stdio.
  2. Subsequent sessions connect to the existing daemon via the socket, sharing a single index.
  3. 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.

Indexing behaviour
  • If workspaceIndexGlobs is set, only .cfc files matching those patterns are indexed.
  • If only workspacePaths is set, all .cfc files under those folders are indexed.
  • If neither is set, the LSP falls back to indexing workspace folders reported by the editor.

Status

  • Initialize / Shutdown / Exit
  • textDocument/didOpen
  • textDocument/didChange (full sync)
  • textDocument/didClose

Local Development

Using a local build in an editor

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 it

The 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 it

make 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.

Make commands
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.

Directories

Path Synopsis
cmd
cfmleditor-lsp command
Package main is the entry point for the cfmleditor-lsp server.
Package main is the entry point for the cfmleditor-lsp server.
cfparse command
Command cfparse benchmarks the cfparser against files or directories.
Command cfparse benchmarks the cfparser against files or directories.
internal
cache
Package cache provides per-file scoped completion caching.
Package cache provides per-file scoped completion caching.
cflint
Package cflint provides CFLint binary management and execution.
Package cflint provides CFLint binary management and execution.
codemap
Package codemap builds a whole-project map of a CFML codebase: every function, every file, and every call, instantiation, inheritance and include between them.
Package codemap builds a whole-project map of a CFML codebase: every function, every file, and every call, instantiation, inheritance and include between them.
codemap/mcp
Package mcp serves a code map over the Model Context Protocol, so an assistant can ask questions about a codebase's structure instead of grepping for them.
Package mcp serves a code map over the Model Context Protocol, so an assistant can ask questions about a codebase's structure instead of grepping for them.
codemap/store
Package store persists a code map in SQLite so it can be queried without rebuilding, and caches per-file parse results so a rebuild only re-reads what changed.
Package store persists a code map in SQLite so it can be queried without rebuilding, and caches per-file parse results so a rebuild only re-reads what changed.
config
Package config defines the shared .cfmleditor.json configuration types.
Package config defines the shared .cfmleditor.json configuration types.
daemon
Package daemon manages multi-session daemon mode and configuration.
Package daemon manages multi-session daemon mode and configuration.
deps
Package deps builds transitive dependency graphs for CFML components.
Package deps builds transitive dependency graphs for CFML components.
docs
Package docs provides built-in CFML tag and function documentation.
Package docs provides built-in CFML tag and function documentation.
formatter
Package formatter walks a tree-sitter concrete syntax tree produced by the cfml grammar and re-emits well-formatted CFML source.
Package formatter walks a tree-sitter concrete syntax tree produced by the cfml grammar and re-emits well-formatted CFML source.
graph
Package graph provides structured graph types and renderers.
Package graph provides structured graph types and renderers.
index
Package index maintains a searchable index of CFML function definitions.
Package index maintains a searchable index of CFML function definitions.
knownissues
Package knownissues reads a file of documented findings — known issues, TODOs, a baseline of unresolved calls — and turns each into an editor diagnostic.
Package knownissues reads a file of documented findings — known issues, TODOs, a baseline of unresolved calls — and turns each into an editor diagnostic.
language
Package language provides tree-sitter language and parser setup for CFML grammars.
Package language provides tree-sitter language and parser setup for CFML grammars.
log
Package log defines the shared Logger interface for the project.
Package log defines the shared Logger interface for the project.
parser
Package parser provides a hand-written recursive descent parser for CFML and CFScript that extracts function definitions, variable declarations, and component references.
Package parser provides a hand-written recursive descent parser for CFML and CFScript that extracts function definitions, variable declarations, and component references.
path
Package path resolves CFML component dot-paths to filesystem paths.
Package path resolves CFML component dot-paths to filesystem paths.
refs
Package refs provides shared logic for finding function/component references across files.
Package refs provides shared logic for finding function/component references across files.
resolve
Package resolve provides component and function resolution logic.
Package resolve provides component and function resolution logic.
route
Package route resolves convention-based framework routes — the dotted strings a dispatcher turns into a component, a method and a view file — to the code they actually reach.
Package route resolves convention-based framework routes — the dotted strings a dispatcher turns into a component, a method and a view file — to the code they actually reach.
server
Package server implements the CFML Language Server Protocol handler.
Package server implements the CFML Language Server Protocol handler.
textdiff
Package textdiff computes the minimal set of line replacements turning one sequence of lines into another.
Package textdiff computes the minimal set of line replacements turning one sequence of lines into another.
tsoracle
Package tsoracle compares what the hand-written parser extracts from a CFML file against what the tree-sitter grammar sees in the same file.
Package tsoracle compares what the hand-written parser extracts from a CFML file against what the tree-sitter grammar sees in the same file.
unresolved
Package unresolved finds the calls the resolver cannot follow to a definition, and writes them as a known-issues file.
Package unresolved finds the calls the resolver cannot follow to a definition, and writes them as a known-issues file.
vfs
Package vfs defines a filesystem interface for portability across native and WASM builds.
Package vfs defines a filesystem interface for portability across native and WASM builds.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL