README
¶
clif
Pronounced "cliff". A Language Server Protocol (LSP) implementation for CFML / ColdFusion, written in Go, and a command-line tool for the same checks.
Uses tree-sitter-cfml.
Formerly cfmleditor-lsp
clif was called cfmleditor-lsp. Everything that used the old name still works, so nothing has to change at once:
| Old name | New name | What still works |
|---|---|---|
cfmleditor-lsp binary |
clif |
Releases publish cfmleditor-lsp-<platform> archives holding the same binary under its old name; make build, make install and make link create both names |
.cfmleditor.json |
.clif.json |
Both are read; a directory holding both is configured by .clif.json |
cfmleditor.* commands |
clif.* |
Both are run; only clif.* are advertised, so a command picker lists each once |
.cfmleditor-cflint.txt, .cfmleditor-unresolved.txt |
.clif-cflint.txt, .clif-unresolved.txt |
A report that exists under the old name, with none under the new one, keeps being read and written there |
CFMLEDITOR_LSP_LOG, CFMLEDITOR_CPUPROFILE, CFMLEDITOR_MEMPROFILE |
CLIF_LOG, CLIF_CPUPROFILE, CLIF_MEMPROFILE |
The old variable is read when the new one is unset |
github.com/cfmleditor/cfmleditor-lsp |
github.com/cfmleditor/clif |
GitHub redirects the old repository URL |
Two things move without a fallback: the daemon's socket directory and the crash
log directory are named clif now, so a daemon started by an older release is
not shared with a newer one, which they never could be safely anyway. A CFLint
already downloaded under the old cache directory is used where it is.
Install
| Platform | Command |
|---|---|
| macOS, Linux | brew install cfmleditor/tap/clif |
| macOS, Linux, built from source (once published; see below) | brew install cfmleditor/tap/clif-source |
| Windows (Scoop) | scoop bucket add cfmleditor https://github.com/cfmleditor/scoop-bucket, then scoop install cfmleditor/clif |
| Windows (winget) | winget install cfmleditor.clif |
| Windows (Chocolatey) | choco install clif |
| Anywhere, an exact version | scripts/install.sh or scripts/install.ps1 (see Pinning a version) |
Homebrew, Scoop and Chocolatey install clif and, for editor extensions that
still look for the old name, cfmleditor-lsp beside it; winget installs
clif. Every release also has archives for each platform on the
releases page. The VS Code and
Zed CFML extensions download a server of their own, so neither needs this.
The release workflow publishes to all four after each release
(.github/workflows/package-managers.yml, which also runs, without
publishing, on a pull request that changes any of them):
- Homebrew: the formulae in
cfmleditor/homebrew-tap:
clif, the release's binary, generated bypackaging/homebrew/formula.sh, andclif-source, the same release built from source with Homebrew's Go, generated bypackaging/homebrew/formula-source.shfrom the source archive each release attaches. Install one or the other.clif-sourcehas no CFLint inside (it uses acflinton PATH, or downloads one on first use), is the formula homebrew-core would take, and is updated only once the workflow has built and tested it with Homebrew's Go. Until Homebrew's Go reaches the versiongo.modnames it is not published, and afterwards it can lagclifby a release or more for the same reason:brew info cfmleditor/tap/clif-sourceshows which release it is. - Scoop: the manifest in
cfmleditor/scoop-bucket,
rewritten by
packaging/scoop/update_manifest.pywith the hash checked against the release'schecksums.txt. The bucket's own scheduled updater would also pick a release up, within six hours. - Chocolatey: the package in
packaging/chocolatey/. Each version is moderated before it is listed, sochoco installlags a release by however long that takes. - winget: a pull request to
microsoft/winget-pkgs, written by
komac from the previous version's
manifest. It is reviewed before it is listed, so
winget installlags a release too.
Each needs a repository secret to publish, listed at the top of the workflow:
HOMEBREW_TAP_TOKEN, SCOOP_BUCKET_TOKEN (or the Homebrew one, granted both
repositories), CHOCOLATEY_API_KEY and WINGET_TOKEN. Without one, that job
checks what it would publish and warns instead.
Build
make build
Or manually:
go build -trimpath -ldflags="-s -w" -o clif ./cmd/clif
The Go toolchain version is pinned in go.mod, and CGO is required (the
tree-sitter grammar is C).
Set up a development environment
Run bash scripts/dev-env.sh check to check the pinned Go compiler, a C
compiler, and HTTPS access. To provision a fresh environment:
bash scripts/dev-env.sh setup
source target/dev-env/env.sh
Setup reads the Go version from go.mod, verifies downloaded toolchains
against Go's published SHA-256 checksum, caches module dependencies, and
builds the pinned linter. It needs Bash, curl, Python 3.11 or newer, tar,
make, and a C compiler. Downloads support Linux and macOS on amd64 and arm64.
Use the setup command as your managed environment's setup hook. The default
cache is gitignored under target/dev-env; CFML_DEV_CACHE can name a
persistent cache directory instead, whose env.sh you then source.
make clean removes the default cache. Existing Go cache variables are
honored, including GOLANGCI_LINT_CACHE. Setup also places the linter's
analysis cache in the writable development cache by default.
In a managed sandbox, a proxy connection failure may be specific to that
execution context. Retry the check with approved network access before
concluding the proxy is unavailable; keep the inherited proxy and CA trust.
See AGENTS.md for the agent startup and validation workflow.
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.2, 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.2 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:
./clif
Configure your editor to launch this binary as an LSP server for .cfm, .cfc, .cfml, and .cfs files.
Logs and crash reports
The server logs to stderr, which the editor keeps in its language-server log. Two files outlast that:
- Crash reports always go to a file, with nothing to configure:
crash.login the server's cache directory. That is~/Library/Caches/clif/crash.logon macOS,~/.cache/clif/crash.logon Linux (or under$XDG_CACHE_HOME), and%LocalAppData%\clif\crash.logon Windows. Each start adds one line naming the version, the process and the time; anything else in the file is a crash record. That covers a crash that ended the process, whatever caused it, and a panic the server recovered from and kept running after, each with its stack. - A copy of the whole log goes to the file named by
CLIF_LOG, when that is set in the environment the editor starts the server with. Crash reports then go to that file instead ofcrash.log.
For a fatal Go error, such as a stack overflow, the one-line reason (fatal error: stack overflow) appears only on stderr. The file gets the traceback that follows it.
Scanning a project for unresolved calls
unresolved reports every call the server cannot resolve to a definition. It takes files or directories and walks directories itself, so there is no glob to pass. It skips dot-directories such as .git and .claude/worktrees. It reads the .clif.json governing the first path it is given.
The whole workspace is always indexed (every folder in workspacePaths), because resolving a call reads every other component. The report holds only what you name: a directory or file argument narrows it, so unresolved packages/tass/core lists calls in that directory alone.
When workspacePaths is omitted, the requested directories also supply the resolver's workspace roots. A config containing only presets or mappings keeps that default. A file target uses its parent directory for lookup while indexing and reporting only that file. explain --root, dependency scans and code maps use the same default; explicit workspacePaths retain priority. To inspect a vendor package that the normal directory walk skips, name it explicitly, for example unresolved myapp myapp/vendor/wheels.
macOS and Linux:
clif unresolved ~/projects/myapp > unresolved.txt
Windows PowerShell (cmd.exe and PowerShell do not expand **/*.cfc, and neither needs it):
clif.exe unresolved "$HOME\projects\myapp" | Out-File -Encoding utf8 unresolved.txt
Windows cmd.exe:
clif.exe unresolved "%USERPROFILE%\projects\myapp" > unresolved.txt
Use >> (or Out-File -Append) to add to an earlier report. Windows PowerShell 5.1 writes UTF-16 for a plain > or >>, hence Out-File -Encoding utf8; PowerShell 7 writes UTF-8 either way. Progress and timing go to stderr, so they stay on the console and out of the file.
Use the binary from target/release/ after make build, or a release download; give its full path if it is not on PATH. Add --json for machine-readable output, --verbose for the reason behind each entry, and --write to write .clif-unresolved.txt beside the config (see Generated reports). clif explain <file> <line> traces one call.
For experimental receiver guesses, use `clif unresolved --speculative --verbose
`. This connects an unknown receiver when every finding in its file/function group offers the same sole candidate component. It can accept incorrect calls; diagnostic reductions are not verified fixes. Verbose stderr lists proposed connections with their source and chosen component. It runs a strict candidate scan followed by a guessed scan, adding indexing and scan cost. Speculation is off by default; `--no-speculative` explicitly switches it off (the last flag wins).With --json --candidates, methodEvidence separates indexed name availability
from receiver ownership. nameStatus is no-source-declaration,
unique-source-declaration or ambiguous-source-declarations; generated source
accessors count, while framework stubs are counted only in indexedDeclarations.
relationship identifies an unproven receiver, return, binding or inheritance,
a missing source/context, or a selected component/native contract mismatch.
ownership remains unproven: a unique name is a candidate, and no source
declaration is not proof of a nonexistent runtime method. These counts cover the
scan's index, not every dependency or project on disk. Broken-base summaries
do not represent individual method searches and omit this evidence.
For chain failures, the evidence and method candidates describe the failed hop;
the existing function and definitions fields still describe the reported
final function. Speculative connection records additionally retain methods
and ownership: "unproven" alongside their policy and proposed component.
Global name uniqueness and a sole compatible receiver candidate are different:
several components may declare one name, while only one answers all the
receiver's observed methods. Neither establishes the receiver's origin.
--speculative-methods adds more liberal per-call shortcuts to workspace source
declarations, including calls whose selected receiver lacks the method and final
methods behind broken upstream chains. It chooses the nearest declaration,
breaking ties by source path, and keeps alternative targets, the uncapped target
count, the original diagnostic and ownership: "unproven". Each chosen target
has low confidence. These shortcuts can connect unrelated or inaccessible methods;
they are recorded separately in speculativeMethodConnections and do not
increase the resolved count. They run before open-world classification, replacing
some runtime assumptions with concrete provisional targets. Broken-base summaries
and calls with no indexed source declaration remain available for review.
clif unresolved --json --speculative-ranked --speculative-native --speculative-methods --assume-open-world project > provisional-connections.json
This mode is off by default; --no-speculative-methods disables it (last flag
wins). Its JSON output is an audit object even without --assume-open-world.
Use --json or --verbose to review the shortcuts; --write and
--known-issues are incompatible with this mode. Strict scan and editor behavior
remain unchanged.
--speculative-ranked broadens component guessing to the unique best candidate
matching the receiver's methods. It distinguishes the original object's first
method from methods on returned objects, intersects all candidates before ranking,
and retains ties and property-first chains as unknown. Names can still match the
wrong origin; a passed number may be guessed to be a component. Use
--no-speculative-ranked to switch it off. Candidate display limits do not limit
this mode's matching pool. It can be combined with --speculative-native.
For a more liberal deployment assumption, use:
clif unresolved --json --speculative-ranked --speculative-native --assume-open-world project > resolution-audit.json
--assume-open-world classifies unknown receivers and returns as runtime-provided,
absent component paths as external dependencies, and unqualified functions as
runtime mixins. These assumptions can conceal misspellings and real defects. They
do not create definitions or increase the resolved count. Known receiver method
errors, explicit this.method() errors, broken chains and missing inheritance
remain findings. The JSON output in this mode is an object with calls,
assumedCalls, speculativeConnections, resolved, indexed and scanned;
each assumed call retains its original diagnostic, location and policy. Ordinary
JSON mode still emits the existing findings array. --write and --known-issues
are incompatible with this audit mode. It is off by default; use
--no-assume-open-world to restore ordinary reporting. These new switches apply
to the batch CLI, not editor diagnostics.
See assumptions, examples and measured experiments.
Native family guesses have a separate switch: --speculative-native (off by
default), with --no-speculative-native to disable them. Existing native
interface families are tried first; if no unique answer exists, a unique
String/Array/Struct/StringBuilder family can provide a fallback. Each stage
requires all observed methods to match. This priority is a usage assumption,
not object-origin proof; verbose output labels primitive fallbacks separately
and marks every guess unproven. Both speculative flags can be used
together. Proven Java/builtin producers carry bounded native method and return
contracts without enabling guesses. --native-engine auto|generic|lucee controls
the Lucee runtime API profile: auto recognizes the marked Lucee source layout;
generic disables that engine-specific producer. These tables assume the
documented Java 17/Hibernate 5.6 interfaces and compatible runtime bridges.
See native contracts and limitations.
For closure of earlier research, see the implementation completion audit.
Batch scans infer untyped arguments from their indexed callers. This search has
no file or call-site limits by default. To bound it, add an optional policy to
.clif.json:
{
"callerInferenceLimits": {
"maxCandidateFiles": 512,
"maxCallerFiles": 60,
"maxCallSites": 40,
"maxConstructorFiles": 60
}
}
maxCandidateFiles counts lexical candidate files for ordinary methods;
maxCallerFiles counts files with calls belonging to the target method;
maxCallSites counts supplied argument sites for methods and constructors;
maxConstructorFiles counts constructor candidate files. Each positive value
enables that limit. Omitted, zero or negative values are unlimited. The policy
block replaces an inherited block as a whole; {} resets all limits.
Exceeding a limit withholds the inferred type, which can leave valid calls
reported as unresolved. Unknown receivers or values also withhold inference.
--no-infer-args disables caller inference entirely. These settings govern batch
caller inference used by the CLI and its explain/MCP scan paths; editor live
resolution does not build the caller index. Large unlimited searches may cost
more work.
Replacing CFLint
clif cflint runs CFLint itself: a native build, so no Java is involved, with
the same rules, the same .cflintrc files (folder overrides, inheritParent
and parameters included) and the same @CFLintIgnore comments. What it
replaces is how CFLint gets invoked. Hooks, CI jobs, scripts and editor
gates all call one command, which exits with a meaningful status on every
platform.
| With CFLint | With clif |
|---|---|
java -jar cflint.jar -file a.cfm,b.cfc -text -stdout |
clif cflint --format text a.cfm b.cfc |
java -jar cflint.jar -folder src -json -stdout |
clif cflint --format json src |
cflint --version |
clif --version |
grep the text for Total issues:([1-9]) |
the exit status: 0 clean, 1 findings, 2 the run can't be trusted. --format text still ends in Total issues:N |
| a hook script per machine | clif hook install, committed in .githooks/ (see Linting) |
| lint every file in a commit | --staged (what is committed), --changed <ref> (what a branch changed), --changed-lines (only the lines it touched) |
| a minimum severity | --min-severity <level>, or --strict for every level |
| (no equivalent) | --format sarif, --baseline <file>, clif suppressions --baseline <file> |
CFLint's -json output names its list issues, not issue, and so does
clif cflint --format json. CFLint wants a comma-separated list for
-file a,b and silently does nothing given -file a -file b. clif takes
plain arguments. A .cflintrc CFLint can't parse makes it lint with its
default rules and say nothing; clif stops with exit 2 instead.
One command for people, CI and agents. Keep the invocation in the repository, so a developer, a CI job and an agent run the same thing:
# Makefile
lint:
clif cflint --strict --format text .
# scripts\lint.ps1
clif cflint --strict --format text .
exit $LASTEXITCODE
CI. Lint what the branch changed on every build, everything on a
schedule, and keep a SARIF file for the CI server or a code-quality tool
(GitHub code scanning, or SonarQube's sonar.sarifReportPaths). The exit
status fails the stage.
clif cflint --changed origin/main --strict --format sarif --out cflint.sarif # each build
clif cflint --strict --format sarif --out cflint.sarif . # nightly
--changed needs the merge base of HEAD and the ref, and CI checkouts are
often shallow or single-branch. Fetch the base branch with enough history
first, for example git fetch --unshallow origin main or a checkout depth of
0. Without it, git can't diff and clif exits 2 rather than passing a build
it never linted.
// Jenkinsfile: the same command on a Linux or a Windows agent
stage('CFLint') {
steps {
script {
def cmd = 'clif cflint --changed origin/main --strict --format sarif --out cflint.sarif'
if (isUnix()) { sh cmd } else { bat cmd }
}
}
}
Agents and editor gates. One file takes about half a second:
clif cflint --format json path/to/file.cfc prints CFLint's JSON, and exit
status 1 means findings. To block only on serious ones, add
--min-severity ERROR.
Pinning a version. Put the version in clif.version (or
scripts/clif.version) at the repository's root. The install scripts read
it, so every developer and every CI agent installs the same build, into a
directory of their own, with no administrator rights:
scripts/install.sh # macOS, Linux: into ~/.local/bin
powershell -ExecutionPolicy Bypass -File scripts\install.ps1 # Windows: into %LOCALAPPDATA%\clif\bin
Each script checks the archive against the release's checksums.txt, which
releases publish from 0.5.1 on. CLIF_DOWNLOAD_URL points
either script at an internal mirror of the releases. Chocolatey installs an
exact version with choco install clif --version <v>; Homebrew's tap holds
only the latest.
Code map
clif 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.
clif graph . # summary to the terminal
clif graph --format html --out map.html . # interactive report
clif graph --level package --format dot . | dot -Tsvg > map.svg
clif graph --db .clif/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
clif.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 .clif/codemap.html,
and the codemap config block supplies the rest. clif.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 .clif.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.
clif 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.
clif routes --unresolved . # to the terminal
clif routes --format md --out routes.md . # a report to keep
clif 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
.clif.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:
clif 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
clif mcp serves a workspace over the Model Context Protocol on stdio,
so an assistant can ask about the code instead of grepping for it. It writes
nothing.
Without a map it offers the checks the CLI runs, read from source on disk:
find_unresolved_calls (what unresolved reports), find_references (refs)
and explain_call (explain), which traces step by step how a call site's
receiver was typed and which componentResolver fired. Paths are relative to the
server's working directory, and the .clif.json above the first one decides
how calls resolve. Lists are capped at 200 by default, with the total alongside.
{
"mcpServers": {
"cfmleditor": {
"command": "clif",
"args": ["mcp", "--allow-lint"],
"cwd": "/path/to/project"
}
}
}
--allow-lint adds lint, CFLint's findings as JSON. It is off by default because
it starts a CFLint process; it writes no report.
--db <file> loads the code map graph --db built, and adds search_symbols,
get_symbol, get_callers, get_callees, find_path, list_islands,
list_orphans and get_stats. --map-only offers those alone and never reads
source.
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 two capabilities it can answer are unavailable when the server is enabled:
- Docblock completion —
@param,@returnand friends inside/** */(would also need@and*added to the completion trigger characters) textDocument/documentColor— colour swatches and the picker
Configuration
Place a .clif.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. .clif.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). A workspace folder already implies a mapping of its own name; see Mappings. |
startupFiles |
No | Templates that set up shared-scope variables for the whole workspace. See Startup files. |
componentResolvers |
No | Custom patterns for resolving method calls to component paths. See below. |
frameworks |
No | Framework presets the project uses, e.g. ["coldbox"]. Each adds the resolvers and implicit base components that framework implies. See Frameworks. |
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. |
docBlock |
No | What /** expands to: gap (default true) puts a blank * line between the hint and the tags, and extra is a list of { "name", "default", "types" } tags added to every block (types limits them to component, interface, function or property). Inside a doc block, @ completes the attributes of the tag being documented and a function's argument names. |
references |
No | textDocument/references support, off by default. See below. |
features |
No | Per-capability switches. documentHighlight, watchedFiles and rangeFormatting default to true; folding and typeDefinition default to false and are opt-in. See below. |
debug |
No | Enable debug logging (zap.NewDevelopment). Outputs verbose logs to stderr. |
Mappings
Recognized standard project layouts can supply missing root and library mappings. Explicit mappings retain priority; the evidence and limitations are documented in Project assumptions.
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.
A workspace folder also stands for a mapping of its own name, so a root that is just a folder's name needs no entry. With "workspacePaths": ["../tassweb"], the dot-path tassweb.packages.core.Kernel resolves to ../tassweb/packages/core/Kernel.cfc, and the include /tassweb/includes/header.cfm to the matching file. This is tried last, after every mapping, every Application.cfc mapping and every relative lookup, so it never changes a path those already resolve. Mappings are still what you need for any root that is not a workspace folder's name: a virtual root such as models above, a directory outside workspacePaths, or a name that should point somewhere other than the folder it matches. An explicit mapping for a folder's own name is harmless; it wins, and says the same thing.
Frameworks
A framework hands every file values it never declares, and lets a file inherit
without saying so. "frameworks": ["coldbox"] teaches the LSP both:
| In a ColdBox app | Treated as |
|---|---|
event, html, controller/cbController/coldbox, log, logbox, wirebox/injector, binder, cachebox, flash (bare, variables. or arguments.) |
The ColdBox component each one is: RequestContext, HTMLHelper, Controller, … |
getRequestContext(), getController(), getRequestService(), getResponse() and the other framework getters, however they are reached |
Their ColdBox return types, so a chain on them is checked |
A .cfc under handlers/ or interceptors/, config/Router.cfc, config/Scheduler.cfc that names no extends |
An EventHandler, Interceptor, Router or ColdBoxScheduler, so a bare getInstance() or route() is found on the base |
A .cfm under views/ or layouts/ |
Rendered by the Renderer, so a bare view() or announce() is found there |
getInstance( "UserService@users" ), getInstance( name = "id:models.UserService" ), however it is reached |
The component the id names, found as an injected property's is (below). A WireBox DSL string, getInstance( "logbox:root" ), is ColdBox's own class. An id with no file behind it is dynamic, and a computed one is left alone. The workspace's modules decide what an id means, as WireBox's do (below) |
A helper ColdBox mixes in: a module's this.applicationHelper or includeUDF( "#moduleMapping#/…" ), the app's applicationHelper setting, a view's <view>Helper.cfm and <folder>Helper.cfm |
Found from any handler, view, layout or interceptor, so cbMessageBox() resolves where its module is installed |
The other presets, named alongside it as a project uses them:
| Preset | Adds |
|---|---|
testbox |
$assert/assert, mockbox, testbox, a reporter's results; what getMockBox(), expect() and expectAll() return |
commandbox |
print (the print buffer); what command() returns; a .cfc under commands/ is a BaseCommand, and task.cfc or a .cfc under build/ a BaseTask |
cfmigrations |
a migration's schema, qb/query, and the table a schema callback is handed, as qb's builders. Common names, so name it only where cfmigrations is used |
contentbox |
the cb helper; prc.oCurrentAuthor, prc.oCurrentSite, and prc.oContent as any kind of content. Use with coldbox |
wheels |
application.wo; model( "User" ) is the User model, found by file name nearest the calling file, so a test's own models come before the application's; a view or layout runs inside its controller, with every view and controller mixin Wheels integrates, so linkTo() and startFormTag() are found |
fw1 |
fw/framework, beanFactory; views and layouts run inside framework.one, so buildURL() is found; getBean( "userService" ) is the bean DI/1 registers under that name (below) |
With a framework's source in the workspace (a mapping, or a checkout of it),
calls are checked against its real methods and go-to-definition opens them.
Without it, the LSP uses the framework's API bundled into the binary: stubs
generated from each framework's own source, with every method's signature and
doc comment. So event. still completes, event.getValue( still shows its
signature, hover shows ColdBox's documentation for it, and event.getValu() is
reported as not found in RequestContext — but go-to-definition answers
nothing, since there is no file to open. The bundled API is for one version of
each framework (pinned in internal/frameworkapi/sources.go); a checkout of
the framework, whatever its version, always wins. A chain that runs beyond what
is bundled is accepted as dynamic, as is anything a preset implies when neither
the source nor its stub has it. Everything a preset adds can be overridden: your own
componentResolvers come first, and a file that names its extends keeps it.
A child config's frameworks adds to its parent's, and an unknown name is
warned about. When the box.json beside the config depends on a framework
with a preset the config does not name (coldbox, testbox,
commandbox-migrations, wheels-core, …), unresolved ends by suggesting it,
and the server logs the same suggestion. Dependency suggestions alone do not enable presets. With no editor config, a recognized source layout can now supply framework defaults; an explicit config controls its framework selection. See Project assumptions for detection rules, mapping defaults and overrides. Over the historical six-project corpus, each project naming the frameworks
it uses, the unresolved report goes from 22,933 entries to 14,027. The
bundled API raised that figure when it came, from 14,083 to 14,665, because
calls it had accepted unchecked were now checked: ContentBox, which uses
ColdBox without its source, reports the same entries with the stubs as with
ColdBox checked out beside it. Typing what getInstance() builds and what the
injection DSL injects then took it to 14,027.
The bundled API reads more than ColdBox declares. Most of ColdBox's methods
declare no return type and document one, so a method whose doc comment says
@return coldbox.system.web.context.RequestContext returns that in the
bundled API: var event = execute( event = "main.index" ) in a test is a
request context, and getMockController() a mock controller. A documented
path that does not exist is looked up by file name within the framework, and
an interface is left out: getCache() is documented as an ICacheProvider,
which declares less than every cache it hands back. The same comments are read
from a framework's own source when it is in the workspace, where the documented
path names a file: there an interface is kept, and a method it does not declare
is accepted as dynamic, since the object behind it is an implementation with
more.
A preset's variable resolvers are nameOnly (below), so in tag syntax
<cfset x = event.getValue( "a" )> does not make x a request context.
Startup files
A REQUEST, SESSION, APPLICATION or SERVER variable is usually set up in one place and read everywhere else, so the file calling REQUEST.context.getUser() has no assignment to learn its component from. The server looks for one in the templates that set the request up:
-
Automatically, in the templates the governing
Application.cfc(or.cfm) includes, and in what they include in turn. This needs no configuration. -
In
startupFiles, for code under noApplication.cfc, or for a template noApplication.cfcincludes. An entry here applies to every file in the workspace:"startupFiles": ["/tassweb/packages/tass/core/bootstrap.cfm"]An entry is a file path, relative to the config or absolute, or a template path starting with
/, resolved as acfincludeof it would be: throughmappings, the workspace folders and a folder named by its first segment.
Assignments of the form SCOPE.name = value are read in tag or script syntax, including multiline expressions. The value is typed if it creates a component (createObject("component", …) or new …), or if it is a chain of calls on another shared variable or on something a componentResolvers entry names, followed through each call's return type. So REQUEST.context = REQUEST.kernel.getContextObject() gives REQUEST.context whatever getContextObject() returns. This is tried after every assignment in the calling file, its Application.cfc and its extends chain.
A bounded script loop over a literal listToArray service list can also type
application["#name#"] = factory.getBean("#name#"). The key and bean ID must use
the same unchanged iterator. The factory must expose the actual getBean and
declareBean argument contracts; bean identities still come from registered or
configured beans. Unknown factories, dynamic lists, missing beans and conflicting
or unknown service replacements remain unresolved.
A configured getBean resolver with "resolve": "$1" also learns literal
declareBean(beanName, dottedPath) and addAlias(aliasName, beanName) registrations
from explicitly configured startup files and their includes. The registration
receiver must resolve to a component exposing those parameter names and getBean.
Alias targets come from beanPaths or literal declarations; alias chains are
followed, including names such as $. This works in script and <cfset> syntax.
Earlier explicit resolver rules retain priority, and new Content() still looks
up a component rather than a bean alias. Computed arguments, missing targets,
conflicting registrations, and cycles provide no inferred type. The include walk
and alias depth are bounded at 64. Runtime addBean values and factory overrides are not inferred by this step.
For a CFC under configured or application-declared beanPaths, a public
setName(Name) method with one generic argument learns the dependency's bean
type, including literal aliases. Whole arguments.Name assignments carry it
into fields, and generated getters return that component. Script, tag, and mixed
syntax share the inference. Declared and documented types retain priority;
private setters, multiple arguments, and methods outside managed scopes do
not acquire a dependency by name. The declared signature stays unchanged in
completion and signature help. Arguments shadow same-named component fields.
Managed files receive the same types during editor indexing and CLI scans, and
bean-map caches refresh when the resolver is invalidated. FW/1 controller/service
directories outside bean roots also qualify when the nearest Application extends
the framework's source and explicitly calls setBeanFactory during setup. The
framework must actually call autowire from its component/controller loader.
Direct conventional directories and older explicit subsystem layouts are modeled;
views, models and nested applications do not acquire injection. Custom directory
layouts, struct-form settings for legacy fallback wiring and separate subsystem
factories remain unsupported. A runtime base override uses only a known literal default. See
.ai/docs/RESOLUTION-GAPS.md for the measured Masa case and boundaries.
Known DI/1 factories apply a separate injection policy. Literal new factory
construction in startup files (including subclasses of DI/1), or a modern
Application.cfc extends="framework.one" with literal framework configuration,
establishes lifetime rules inside the factory's literal folders. Transient
folders/patterns, singleton patterns, exclusions, singular mappings, aliases,
constants and explicit registrations constrain setter/property types. Direct
getBean retrieval still permits transients. DI/1 properties require accessors
or persistence and honor setter, typed/defaulted-property flags; matching ignored
properties also block explicit setters. Global document edits refresh metadata.
Masa's older FW/1 fallback controller wiring retains its containsBean policy.
Factory-managed public init methods now infer generic constructor arguments
by bean name, including transient dependencies. Whole argument assignments carry
those types into fields and generated getters. Declared/documented types remain
unchanged. Literal declaration overrides and observed startup getBean argument
overrides withhold matching dependency types; dynamic overrides and registered
instances/constants do not imply factory construction. Unmanaged/private init
methods and custom construction implementations receive no constructor inference.
This policy uses existing static bean identities; automatic discovery of additional bean roots, delegated applications and subsystem factory inheritance remain follow-up work. Dynamic/conflicting lifetime configuration and unsupported regular expressions produce no injection type in a recognized factory. Runtime argument overrides outside configured startup sources, computed registrations and custom injection implementations remain outside this static model.
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 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 — clif 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.
nameOnly
In tag syntax, <cfset style = document.loadStylesheet()> types style as whatever a
resolver says document is: an assignment from a call on a variable that a resolver
matches by name takes that variable's component. A project whose one stub answers every
call on a family of objects relies on that. A resolver that says what a variable holds,
and nothing about what calls on it return, sets "nameOnly": true: x = document is still
typed, x = document.load() and x = document[ "k" ] are not. The framework presets' variable
resolvers are all nameOnly.
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 and typeDefinition are the exceptions:
they are off unless you ask for them.
{
"features": {
"documentHighlight": true,
"folding": false,
"watchedFiles": true,
"rangeFormatting": true,
"typeDefinition": false
}
}
| Key | Default | Controls |
|---|---|---|
documentHighlight |
on | Shading the other occurrences of the identifier under the cursor. |
folding |
off | Folding ranges for functions and comments; in CFScript every block, closure, literal, multi-line argument list and switch case; in markup every element, CF tag and <cfelse> branch. 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 clif.reindex is the way to refresh it. |
rangeFormatting |
on | "Format Selection". Switching it off leaves whole-document formatting and format-on-save working. |
typeDefinition |
off | "Go to Type Definition": from a variable, argument or property to the component it holds, or from a call to the component the function returns. The newest capability here, so it waits to be asked for. |
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. An editor that is given folding ranges uses them in place of
its own indentation folding, so it is a change to how every file folds. Turn it on
with {"features": {"folding": true}}.
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
.clif.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. A released
clif carries CFLint inside it (the native build, so no Java), at the version
scripts/fetch-cflint.sh pins, and unpacks it into its cache the first time it
lints, so linting needs no download. CFLint's licence is in
THIRD_PARTY_NOTICES.md, which each release archive
carries. A cflint already on PATH wins. A clif
built from source has none inside, and downloads the latest from the
cfmleditor/CFLint releases on first use:
{
"linting": { "enabled": true, "minSeverity": "WARNING" }
}
On a machine that will not run programs from the user's cache or temp folder
(AppLocker or WDAC default rules, a noexec mount), two environment variables,
set where clif or the editor starts, take over:
| Variable | Effect |
|---|---|
CLIF_CFLINT |
The CFLint binary to run, ahead of PATH and the copy inside clif. An error if it names no executable file |
CLIF_CFLINT_DIR |
Where clif unpacks (or downloads) CFLint instead of the cache, as <dir>/<version>/. An error, not a fall-back to the temp folder, if it cannot be written |
Use absolute paths: a relative one is read from the directory clif starts in, which for the editor's server is not your shell's.
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": ".clif-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 .clif.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, .clif-unresolved.txt and
.clif-cflint.txt beside the .clif.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 |
clif.exportUnresolved, or clif unresolved --write <project> |
.clif-unresolved.txt |
cflint |
clif.exportCFLint, or clif cflint --write <project> |
.clif-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 covers only what was asked for: the directories given to
clif cflint, or the folders open in the editor for
clif.exportCFLint. The config's own directory stands for the
workspacePaths folders beneath it, never the ones outside it, which a
project's config lists for resolution. Both refuse to rewrite a report whose
directory holds more than was linted, since that would drop everything else's
entries; the unresolved report still scans every workspace folder. --out <file> writes one report
anywhere above the linted directories, with paths relative to it:
clif cflint --out myapp/.clif-cflint.txt myapp.
clif cflint is also a front end for CFLint that a git hook, CI or a script
can call directly, with no Java:
clif cflint --staged --strict --format text # pre-commit: the staged content, every level fails
clif cflint --changed origin/main --format sarif --out cflint.sarif # CI: what the branch changed
clif cflint --format json src/ # CFLint's own -json schema
- What is linted. Paths are files or directories.
--stagedlints each staged.cfm/.cfcfile as staged: the index content, through CFLint's-stdin, so an unstaged edit neither hides nor adds a finding.--changed <ref>lints the filesgit diff <ref>...HEADlists. With no paths, both take the whole repository. - Formats.
reportis the known-issues report above, and the default.textis CFLint's-textreport, ending in itsTotal issues:Nline.jsonis CFLint's-jsonreport: each issue exactly as CFLint wrote it, underissues, withcountsrecomputed over the whole run.sarifis SARIF 2.1.0 with CFLint's codes as rule ids, for CI servers and code-quality tools.--out <file>writes any of them to a file. - Severity.
--min-severity <level>reports, and fails on, CFLint levels at or above it, overridinglinting.minSeverity.--strictreports and fails on every level. - Configuration.
.cflintrcfiles and@CFLintIgnorecomments are CFLint's own, and apply exactly as they do when CFLint is run directly. CFLint says nothing about a.cflintrcit can't parse and lints with its default rules instead, so clif parses every.cflintrcCFLint would read first and stops the run if one is broken.
A pre-commit hook. clif hook install writes a pre-commit hook into
.githooks/, at the repository's root, and points this clone's
core.hooksPath at it. The hook runs clif cflint --staged --strict.
clif hook install # lint what is committed; every CFLint level fails
clif hook install --changed-lines # fail only on lines the commit adds or alters
clif hook install --log ~/logs/cflint-commits.log # also log each attempt
clif hook check # exit 1 if a repo with a .cflintrc lacks the hook
Commit .githooks/, so the hook is in the repository. Git doesn't copy
core.hooksPath, so each clone runs clif hook install once, and
clif hook check (in CI, or a setup script) catches one that hasn't, rather
than leaving it silently unlinted. Some more behaviour:
- Without clif: on a machine without clif, the hook stops the commit and says so.
- Skipping:
SKIP=cflint(orPREK_SKIP=cflint) skips it for one commit. - Other hooks: install leaves a hook it didn't write alone, unless given
--force. - The log:
--logappends one line per commit attempt,TIMESTAMP | TICKET | HASH | PASS/FAIL | REPO | MESSAGE. Apost-commithook fills in the hash and message, so a blocked commit keeps--------.CLIF_COMMIT_LOGoverrides the path.
With pre-commit or prek instead, list clif's hook in the committed config:
repos:
- repo: https://github.com/cfmleditor/clif
rev: v0.5.1
hooks:
- id: clif-cflint
Adopting rules on code that already breaks them. Two ratchets let a project turn a rule on without fixing or suppressing everything first, and stop the debt growing:
clif cflint --out cflint-baseline.txt . # once: record today's findings
clif cflint --baseline cflint-baseline.txt --format text . # fail only on findings not in it
clif suppressions --baseline suppressions.json --update-baseline . # once
clif suppressions --baseline suppressions.json . # fail if @CFLintIgnore counts rose
--baseline <file>reports, and fails on, only the findings the file doesn't list. The file is a known-issues report, as--outwrites one. A finding matches by file, rule and message, not line, so an edit above it doesn't make it new. Entries no longer found are listed on stderr, so the baseline can be regenerated to lock the fixes in.clif suppressionscounts@CFLintIgnoretags and// cflint ignore:comments by rule, reading code lists as CFLint does:@CFLintIgnore A, Bsuppresses, and counts, onlyA. With--baselineit fails (exit 1) when the total or any rule's count rises, so a new suppression needs a deliberate, reviewable baseline change.--update-baselinewrites it.
Exit status: 0 when there is nothing to report, 1 when there are findings,
and 2 when the run can't be trusted: a path that doesn't exist, a broken
.cflintrc, CFLint unavailable or failing, not a git repository for
--staged/--changed, or a usage error. A known-issues report written with
--write or --out exits 0 whatever it holds, as a regeneration. -q drops
the progress on stderr. clif --version prints the version and exits, like
clif version.
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.
A base component that does not resolve is one entry, not one per call. A
call a file inherits — a bare expect(), this.helper(), super.init(), or a
call on a name the file never declares, such as CommandBox's print or
TestBox's $assert — is looked for up the extends chain. When a link of that
chain names no file, the call was never checked, and the report says so once,
on the file's extends line:
tests/specs/UserSpec.cfc:1: testbox.system.BaseSpec (base component does not resolve; 212 inherited calls not checked)
A call on a component whose own chain breaks is unchecked for the same reason —
settingService.findWhere() where the service extends cborm's
VirtualEntityService and cborm is not on disk — and the calls on such
components in a file are one entry, on the first of them:
handlers/settings.cfc:48: cborm.models.VirtualEntityService (calls a component whose chain breaks at cborm.models.VirtualEntityService, which does not resolve; 12 calls not checked)
The command line also totals them after the list, most calls first, since each
is usually one mappings entry or workspacePaths directory away from checking
every call it accounts for. Over six open-source projects scanned with no
configuration, 59,600 of 89,000 entries were calls into 67 such bases, and a
dozen of them held 57,000. With these and the defaults below the report is 22,300 entries.
An injected property is typed by its id. With no beanPaths entry or
propertyResolvers rule to say otherwise, property name="svc" inject="UserService@users" holds the component UserService, and so do
inject="id:UserService@users", inject="model:UserService" and
inject="provider:UserService" — WireBox's convention is that a model's id is
its file's name, and a provider stands in for what it provides. The component
is found by path, then by file name, nearest first; one that is not on disk is
reported as not existing, which names the missing dependency.
The injection DSL's namespaces name ColdBox's own objects, and each is typed
as the class it injects: coldbox the Controller, coldbox:requestService
and the other services, coldbox:renderer, coldbox:flash,
coldbox:asyncManager, wirebox the Injector, wirebox:populator the
ObjectPopulator, cachebox the CacheFactory, cachebox:<name> a cache,
logbox the LogBox and logbox:logger:{this} a Logger. So are the models
ColdBox registers under its own name, HTMLHelper@coldbox,
Renderer@coldbox, DataMarshaller@coldbox and XMLConverter@coldbox — a
file-name search for those would find whatever the workspace calls
Renderer.cfc. With ColdBox checked out, or the coldbox preset's bundled
API, calls on them are checked; with neither they are dynamic, since that is
ColdBox being absent rather than a finding. The rest of the DSL
(coldbox:setting:x, coldbox:moduleSettings:x, wirebox:child:x) names
something that is not a component and is left alone.
Mappings in Application.cfc are read as the engine builds them. A
mapping is often built from the file's own directory or a variable set just
above it — this.mappings[ "/cli" ] = local.projectRoot & "cli/" after
local.projectRoot = expandPath( "../../../" ) — and those are evaluated, as
are getDirectoryFromPath( getCurrentTemplatePath() ) and earlier mappings.
A mapping whose name has several segments (/modules/wheels) is used for
paths under it, the longest match first.
Literal includes and relative parent constructors are read with the declaring
template's path context, bounded to 64 source files and 16 levels. Bracket/dot
assignments, whole mapping literals and static left/right path slicing are
supported. Runtime expressions are not executed.
Physical non-root mappings in .cfconfig.json, or a static server.json
cfconfig.file selection, supply defaults. Application mappings override those
and explicit editor mappings override application values, case-insensitively.
Relative CFConfig paths are based on that config file; environment placeholders
and archive-primary entries are not guessed. Watched CFML/JSON edits refresh
mapping and resolver caches. Nested applications keep their own mapping context;
recognized framework layouts can supply their declared library namespaces across
those boundaries (see .ai/docs/PROJECT-ASSUMPTIONS.md). Other caller mapping contexts for
library definitions behind a different Application remain a limitation, so
explicit workspace mappings can still be needed.
A framework's own namespace needs no preset. coldbox.system.*,
testbox.system.*, commandbox.system.*, qb.models.*,
contentbox.models.* and cborm.models.* resolve to the bundled framework
API when nothing on disk does, so a component extending
testbox.system.BaseSpec is checked without naming the testbox preset. A
bare id no file answers, getInstance( "PackageService" ) in a CommandBox
command, is looked for among CommandBox's services and utilities, which is
where CommandBox's WireBox maps them.
WireBox ids are read the way the workspace's modules register them. A
module registers its models under its this.modelNamespace, which defaults to
its directory's name, so UserService@users is the UserService in the
users module's models/, not whichever UserService.cfc is nearest. A
module's this.cfmapping is a mapping to its directory, and binder.map( "Mailer" ).to( "#moduleMapping#.models.MailService" ) in a ModuleConfig.cfc
or config/WireBox.cfc makes Mailer that component. An id whose module is
not in the workspace — RequestStorage@cbstorages without cbstorages — is
dynamic, since that is the module not being installed.
ORM entities are found by the name code uses. entityNew( "cbRole" ) and
entityLoad( "cbRole" ) find the component declaring
entityname="cbRole", whatever its file is called. A cborm
VirtualEntityService bound with super.init( entityName = "cbRole" )
returns that entity from new(), get(), getOrFail() and findWhere(), and
its dynamic finders, this.findBySlug( slug ), are answered by
onMissingMethod: any method called through this. on a component that has
one is accepted, while an unscoped call, which CFML never hands to
onMissingMethod, is not.
Methods a framework adds at run time are found. A WireBox delegate —
property name="memory" inject delegate delegatePrefix; or
component delegates=">Memory, Worker=vacation" — gives the component the
delegate's methods under the names WireBox gives them (memoryRead()), and a
bare inject injects the model named by the property. CFML's ORM generates
hasX(), addX() and removeX() for a relationship property, and those are
declared too. With the wheels preset, the application's
global/functions.cfm, and what it includes, reaches every controller, model
and view. A Wheels model's findByKey(), findOne(), new() and create()
return the model they are called on, and an association declared in its
config() — hasMany( "comments" ), belongsTo( "author" ) — gives it the
methods Wheels documents for one (comments(), commentCount(),
newComment(), author(), …), typed as the associated model. An object
declaring this.memento has mementifier's getMemento(). A ColdBox handler
or interceptor has the helpers the cbi18n ($r(), getResource(), …), cbfs
(cbfs()) and HTMLHelper (addAsset()) modules mix in, whether or not the
module is on disk.
DI/1 beans are found by the names DI/1 gives them. In an FW/1 application
(an Application.cfc extending framework.one) the bean factory's default
folders are model and controllers, and a component is registered both
under its file name and under its name followed by its folder's singular:
services/user.cfc is user and userService. A property named after a bean,
property userService;, holds it.
Some values are typed without configuration. Each rule below is a default
that a mappings entry, componentResolvers rule or javaStubsPath still
overrides:
| Source | Treated as |
|---|---|
coldbox.system.EventHandler in a checkout of the package whose box.json slug is coldbox |
The package's own system/EventHandler.cfc: a package spells its components under the name it is installed as |
A method whose return is return this; (or <cfreturn this>) |
Its own component, so a fluent chain is checked hop by hop |
expect( x ).toBe( 1 ), a call chained on an unqualified one |
A call on what expect returns, found in the file, its extends chain or its includes |
cfheader( … ), cfhttp( … ) — any documented tag called as a function |
A builtin |
createMock( "models.User" ), createEmptyMock( className = "models.User" ) (MockBox) |
A mock of that class: a call on it is checked against the class, and dynamic when the class does not resolve |
prepareMock, createStub, a mock of a computed class |
Dynamic: a mock's methods are added at runtime |
createObject( "java", … ) and new java:… with no stub for the class |
Dynamic: there is nothing to check a Java object against |
new Query(), new http(), new dbinfo() and the engine's other script-tag components; org.lucee.cfml.*, com.adobe.coldfusion.* |
The engine's own component, never another file of that name. Checked against its source when the workspace holds it by that path (a Lucee checkout's org/lucee/cfml/), otherwise dynamic |
struct(), sessionTouch() and Lucee's other functions its published docs leave out |
A builtin |
A call chained on a method onMissingMethod answers |
Accepted, and the rest of the chain is dynamic, as it already was for a last call |
$(), $results(), $never() and the other methods MockBox adds to a mock |
Accepted on any component, since a test mocks a real component in place |
A bare-word return type, Task function task(), naming a component beside the declaring one |
That component, as CFML finds it; a CFML type name (query, struct, …) stays the type |
A relative return, return new tasks.Future() or tasks.Future function f() |
Read from the declaring file's directory, not the caller's |
A function with no declared type, or any, whose doc comment says @return models.User |
That component, where the path names a file |
return svc.load() |
What load() returns, not the component svc holds; a call on a dynamic value is dynamic |
| A setter CFML generates for a property | Its own component, so setError( true ).setStatusCode( 401 ) chains |
| A getter CFML generates for a property | What the property holds: its type, or what its inject names |
| A method declared on a base class to return that class, called on a subclass | The subclass, since a fluent method returns this: cborm's add() on a CriteriaBuilder |
| An unqualified call, in a function, to a method the component inherits and whose result is assigned | Typed by that method's return, as a call on another component is |
beanPaths and propertyResolvers apply to the report and the code map as
they do in the editor.
To print a report instead of writing it, clif 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 clif.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, .clif.json. The payload has exactly the same shape as the file.
In Zed, via settings.json:
{
"lsp": {
"clif": {
"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 .clif.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.clif.jsonfor that one.
Daemon mode
When .clif.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 .clif.json with a workspaceName to get the sharing.
Indexing behaviour
- 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.
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/clif, 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 clif 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 clif earlier on PATH.
Make commands
| Command | Description |
|---|---|
make build |
Update grammar, generate docs, and build the binary with CFLint inside, as releases are (go build ./cmd/clif builds without it). |
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 clif to. |
make clean |
Remove build artifacts. |
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
cfparse
command
Command cfparse benchmarks the cfparser against files or directories.
|
Command cfparse benchmarks the cfparser against files or directories. |
|
cfstubgen
command
Command cfstubgen generates the framework API stubs in internal/frameworkapi/stubs from each framework's own source.
|
Command cfstubgen generates the framework API stubs in internal/frameworkapi/stubs from each framework's own source. |
|
clif
command
Package main is the entry point for the clif server.
|
Package main is the entry point for the clif server. |
|
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 CFML workspace over the Model Context Protocol, so an assistant can ask questions about a codebase instead of grepping for them: its structure from a code map, and the checks the CLI runs (unresolved calls, references, CFLint).
|
Package mcp serves a CFML workspace over the Model Context Protocol, so an assistant can ask questions about a codebase instead of grepping for them: its structure from a code map, and the checks the CLI runs (unresolved calls, references, CFLint). |
|
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 .clif.json configuration types.
|
Package config defines the shared .clif.json configuration types. |
|
conv
Package conv holds the range-checked integer conversions the rest of the code uses where Go arithmetic in int meets a narrower type: an LSP position's uint32 line and column, above all.
|
Package conv holds the range-checked integer conversions the rest of the code uses where Go arithmetic in int meets a narrower type: an LSP position's uint32 line and column, above all. |
|
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. |
|
frameworkapi
Package frameworkapi serves the API of the frameworks the presets name when their source is not in the workspace.
|
Package frameworkapi serves the API of the frameworks the presets name when their source is not in the workspace. |
|
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. |
|
nativetype
Package nativetype describes public runtime interfaces, separately from CFCs.
|
Package nativetype describes public runtime interfaces, separately from CFCs. |
|
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. |