cfformat is a code formatter for CFML written in Rust. It will format both
script and tag based code (.cfc, .cfs, .cfm). It reads a file, prints
it again at a column width with consistent spacing, indentation, quotes and
line breaks, and leaves what it cannot format untouched. In template files,
any <script> and <style> blocks that hold no CFML code, or only #expr#
and ## inside <cfoutput>, are
formatted too, in process, by the oxc formatter crates:
the output is Prettier's, at cfformat's width and indentation and with the
options of the project's .prettierrc when there is one, and nothing needs
to be installed besides the one binary.
It is a rewrite of the CommandBox cfformat
module and reads its
.cfformat.json settings files. Please note that some settings have been
changed or updated (see Migrating from
CommandBox).
The code in this repository was written by Claude, working from a design plan
and a log of decisions. Every commit Claude wrote carries a Co-Authored-By
trailer naming it.
Prettier's document printer is ported into
crates/cfdoc (see Licence).
cfformat --check . # list the .cfc and .cfs files under . that would change (exit 1 if any)
cfformat --check --cfm . # the .cfm files too
cfformat --diff src # show the changes as unified diffs
cfformat -w src # write them
cfformat -w --cfm . # every .cfc, .cfs and .cfm file under .
cfformat Foo.cfc # one file: the formatted text on stdout
cfformat 'views/**/*.cfm' -w # a glob (quoted, so the shell leaves it alone)
cfformat --git staged -w # the staged .cfc/.cfs files (see "Git")
cfformat arrange -w src # order each component's functions (see "Arranging members")
A directory is walked for .cfc and .cfs files, and .cfm files when
--cfm is specified, skipping hidden entries and whatever .gitignore
(inside a git repository) and .ignore exclude. A file named on the
command line is formatted whatever its extension. A flag with no input is an error — name .
to format the current directory — so a stray cfformat -w never rewrites a
tree; cfformat alone prints the help.
From an editor, pass the text on stdin and the file's path with
--stdin-filepath: the settings that apply to that path are used, the path
need not exist, and the result is printed on stdout.
cfformat --stdin-filepath src/models/User.cfc < src/models/User.cfc
--stdin (or -) reads stdin without a path. cfformat --help lists every
flag.
The mode — CFScript or tags — comes from the file name where the engines
make it certain: a .cfs file is script and a .cfm file is a tag-mode
template, whatever they start with. A .cfc (which holds either a script
component or a <cfcomponent>), a file of any other name, and stdin
without a path are read from the source: script when it starts with a
component or interface declaration (abstract / final included), an
import, or a // or /* comment that no tag follows (<!--- --->
comments above any of these do not count); anything else is a tag-mode
template. --stdin-filepath counts as the name. --script and --tags
force either mode, over the name too; a script snippet on stdin without
such a start (x = 1;) needs --script.
Download the binary for your platform from the latest
release, save it
as cfformat (cfformat.exe on Windows) in a directory on your PATH,
and make it executable. The commands below all run it as cfformat. Every
release carries cfvet, a second binary, as a second set of assets with
the same names (cfvet_linux_x86_64, …); see cfvet. For example,
on Linux x86_64:
curl -fL -o ~/.local/bin/cfformat \
https://github.com/jcberquist/cfformat/releases/latest/download/cfformat_linux_x86_64
chmod +x ~/.local/bin/cfformat
On Windows, in PowerShell (then add %USERPROFILE%\bin to PATH if it
is not there already):
mkdir -Force $HOME\bin
Invoke-WebRequest -OutFile $HOME\bin\cfformat.exe `
https://github.com/jcberquist/cfformat/releases/latest/download/cfformat_windows_x86_64.exe
The assets:
| Asset | Platform |
|---|---|
cfformat_linux_x86_64 |
Linux x86_64 (glibc) |
cfformat_linux_x86_64_musl |
Linux x86_64, static (musl) |
cfformat_linux_arm64 |
Linux arm64 (glibc) |
cfformat_linux_arm64_musl |
Linux arm64, static (musl) |
cfformat_windows_x86_64.exe |
Windows x86_64 |
cfformat_macos |
macOS, universal (arm64 and x86_64, 10.13 or newer) |
The same six exist for cfvet (cfvet_linux_x86_64, cfvet_macos, …).
Each release also carries LICENSE and LICENSE-PRETTIER (see
Licence).
You can also build and install it with Cargo. It requires Rust 1.97 or newer
and what Rust itself needs on your platform to link a program: a C linker and
the platform's build tools (build-essential or similar on Linux, the Xcode
command line tools on macOS, the Visual Studio C++ build tools for the MSVC
toolchain on Windows). Every dependency is pure Rust, so no other native
library is needed:
cargo install --locked --git https://github.com/jcberquist/cfformat cfformat-cli
cargo install --locked --path crates/cfformat-cli # from a checkout
The package is cfformat-cli (the command line) and the binary it installs is
cfformat; the cfformat package is the library (see Library).
cfformat is not on crates.io: it depends on the oxc formatter crates, which
are not published there either, by git.
Settings live in .cfformat.json. For each file, the nearest one walking up
from the file's directory — stopping at the repository root, the first
directory holding .git — applies, whole; only when there is none is
~/.cfformat.json used. --config FILE is merged over it, key by key.
cfformat settings src/Foo.cfc # which files apply, and the options they make
cfformat settings --schema # a JSON Schema for .cfformat.json
cfformat settings --migrate # rewrite an old ./.cfformat.json for the current keys
cfformat doc src/Foo.cfc # the document the printer sees (debug)
Every option, its default and an example of each
value: SETTINGS.md.
See Migrating from CommandBox for what has
changed in the settings.
The <script> / <style> formatting reads the project's Prettier or oxfmt
configuration (.prettierrc, .oxfmtrc.json, a package.json "prettier"
key), from the file's directory upward, for the options that change a
program's style (singleQuote, semi, trailingComma, …); the width and
indentation are always cfformat's. "islands.config": "off" ignores those
files, and "islands.js", "islands.css" or "islands.json": "off" prints
that kind of block as written.
A block inside <cfoutput> whose only CFML is #expr# and ## is
formatted too: the formatter sees each #expr# as a name as wide as it
prints and each ## as #, and gets them back as they were. A string
holding a #expr# keeps its quote, since what the expression emits may
hold either; a block whose #expr# would not come back as they went (a
parenthesis around one dropped, a comma added after one before ], an
operator now against one) prints as written with a warning.
<cfoutput>
<script>
var api = { url : '/sites/#prc.slug#/items', token : #toJson( prc.jwt )# }
$( '##form' ).validate()
</script>
</cfoutput>prints as
<cfoutput>
<script>
var api = { url: '/sites/#prc.slug#/items', token: #toJson(prc.jwt)# };
$("##form").validate();
</script>
</cfoutput>"islands.interpolated": false leaves every such block as written, with no
warning, while blocks with no #expr# or ## are still formatted.
cfformat reads CommandBox cfformat's .cfformat.json files, but the output
is not the same: the script layout now follows Prettier's rules, some
defaults changed, and settings files are found differently. Expect a diff
the first time you run it over a formatted project.
The commands.
| CommandBox | cfformat |
|---|---|
cfformat run Foo.cfc / fmt Foo.cfc |
cfformat Foo.cfc (stdout) |
cfformat run dir/ (overwrites, after a prompt) |
cfformat -w dir (no prompt; without -w, nothing is written) |
cfformat run … --cfm |
--cfm |
cfformat run PATH SETTINGS_FILE |
cfformat --config SETTINGS_FILE PATH |
cfformat check dir/ (--verbose) |
cfformat --check dir (--diff) |
cfformat git staged / unstaged / all |
cfformat -w --git staged / unstaged / all (no git add; see Git) |
cfformat tag-check dir/ |
cfformat --check --cfm dir: an unbalanced tag prints path:line: not formatted: … |
cfformat settings show PATH |
cfformat settings PATH |
cfformat settings info KEY |
SETTINGS.md |
cfformat settings wizard |
none; cfformat settings --schema gives editors completion for .cfformat.json |
cfformat watch, stats, server |
none |
Which settings files apply. CommandBox merged every layer: its
defaults, then ~/.cfformat.json (or the cfformat.settings config
setting), then the project's file, then the file named on the command line.
cfformat uses the nearest project file alone; ~/.cfformat.json is read
only when there is no project file, and cfformat.settings is not read.
A project that relied on keys from someone's home file must now set them
in its own file. --config is still merged over whatever was found.
Keys. An old file loads with a warning for each removed or renamed key
and each old value, on every run. cfformat settings --migrate [FILE]
(default ./.cfformat.json) rewrites it once to the current keys, so that
it loads silently with the same options:
- Removed, now fixed behavior:
binary_operators.paddingand.newline_indent,for_loop_semicolons.padding, everykeywords.*key,function_*.spacing_to_group,function_*.group_to_block_spacingandfunction_*.empty_padding. The output is alwaysif (x) {,} else {,function f() {,a + band(); the one exception is(/)padding inside keyword groups, which now followsparentheses.padding(sofor ( … )pads too when it is on). - Merged:
function_call.padding,function_declaration.paddingandfunction_anonymous.paddingareparentheses.padding;metadata.key_value.padding,param.key_value.paddingandproperty.key_value.paddingareattributes.key_value.padding; each construct'smultiline.comma_dangle,multiline.leading_commaandmultiline.leading_comma.paddingare onemultiline.comma("trailing","dangling","dangling_all","leading","leading_tight"):"dangling"puts a comma after the last item of struct and array literals only,"dangling_all"of argument and parameter lists too. Old keys that dangle literals and not argument or parameter lists become"dangling", keys that dangle all of them"dangling_all"; any other disagreement warns, and the first key in the file wins. - New values:
strings.convert_nested_quotestakes"always","never"or"fewer_escapes"; its oldtrueandfalseload as"always"and"never"with a warning, and--migraterewrites them. - Replaced:
*.multiline.min_length, which measured the whole list, is*.multiline.min_item_length, which measures the items' average width (see below).
Output that changes. Each of these moves code on the first run. Where there is a setting that gives back CommandBox's output, it is named; a file written by CommandBox's settings wizard names every key, so it already sets the first three.
- Lists break only when they do not fit. CommandBox broke a struct, array,
argument or parameter list of 4 or more items one per line once the list
was over 40 columns. Every
*.multiline.element_countnow defaults to 0, which turns that threshold off. Setting a count back to 4 restores it, but measured by the items' average width (*.multiline.min_item_length, 8 columns; 12 for metadata,paramandpropertyattributes), so[1, 2, 3, 4, 5, 6]stays on one line where CommandBox broke it. - Method chains stay on one line while they fit.
method_call.chain.multilinedefaults to 0 (CommandBox: 3, which broke every chain of three calls a call per line). At 0 a chain follows Prettier's rules; set 3 to get CommandBox's. - A quote inside a string no longer forces the other quote.
strings.convert_nested_quotesdefaults to"fewer_escapes":"it's"stays as it is, where CommandBox printed'it''s'."always"is CommandBox's. - The layout is Prettier's, with no setting: call arguments hug a trailing
function or struct, and a broken binary expression, ternary or
forheader breaks as Prettier's does. <cfscript>,<script>,<style>,<cfquery>and<cfjava>bodies are indented one level inside their tag, as Prettier indents<script>and<style>. CommandBox printed these bodies at the tag's own indent;"tags.islands.indent": falsekeeps that.- A run of blank lines prints as one, in script as in tags; CommandBox kept every blank line between statements.
- Templates are formatted in full.
cfformatformats the CF and HTML tags of any template, and the<script>and<style>blocks in it with Prettier's rules, those inside<cfoutput>holding#expr#included. Two new keys choose how tag bodies indent:tags.body.indentfor CF tag bodies, andtags.islands.indentfor the bodies of<cfscript>,<script>,<style>,<cfquery>and<cfjava>;"islands.js","islands.css"and"islands.json": "off"leave those blocks as written, and"islands.interpolated": falseonly the ones holding#expr#or##.
Code between a cfformat-ignore-start comment and a cfformat-ignore-end
comment prints as written, markers included (only its line breaks follow
the newline setting, like the rest of the file); everything around it is
formatted. In script, the markers are // comments on lines of their
own, or /* … */ comments:
x = {a: 1, b: 2};
// cfformat-ignore-start
matrix = [
1, 0, 0,
0, 1, 0,
0, 0, 1
];
// cfformat-ignore-endIn a template, they are CFML comments:
<!--- cfformat-ignore-start --->
<cfif x><td>#a#</td><cfelse><td>#b#</td></cfif>
<!--- cfformat-ignore-end --->A start marker with no end marker after it runs to the end of the file.
@formatter:off and @formatter:on, the formatter markers of JetBrains
IDEs and Eclipse, work the same way, in the same comment forms, so a
region marked for the IDE is left alone here too:
// @formatter:off
matrix = [1, 0,
0, 1];
// @formatter:onEither end marker closes either start marker, and the markers match in
any case (// CFFORMAT-IGNORE-START).
cfformat arrange orders the members of each component and interface,
script or tags:
- Functions always: the functions named by
--firstfirst, in that order, whatever their access (initby default;--first before,initkeepsbeforeabove it;--first=names none), then by access, widest first (remote,public,package,private), then by name, ignoring case. The access is thepublic/private/package/remotemodifier or theaccessattribute, andpublicwhen neither is given;static,abstractandfinaldo not change the order. - Properties with
--properties: by name, ignoring case, within each group: a blank line between two properties ends a group, so properties grouped by hand (injections, then data) are ordered among themselves and the groups stay where they are. Functions cross blank lines.
cfformat arrange --check src # list the components out of order (exit 1 if any)
cfformat arrange --diff src # show the moves
cfformat arrange -w src # write them
cfformat arrange -w --properties src
cfformat arrange -w --first before,init src # `before`, then `init`, then the rest
It takes the bare command's inputs, output modes and exit codes (-,
--stdin-filepath, --files-from, --git, -j, --quiet, --script /
--tags); it walks .cfc and .cfs files (a .cfs holds no component,
so it is left as it is) and reads no settings.
A member moves with the comments attached to it: those directly above it with no blank line between, and one at the end of its last line. Anything else stays where it is, and a member only moves among the members next to it, so these divide a component into sections that are each ordered on their own:
- a comment not attached to a member: one followed by a blank line, or a
banner such as
// ---- PRIVATE ----(a one-line comment framed by rule characters never attaches, even with no blank line below it); - a statement (pseudo-constructor code,
static { }), an ignored region,<cfscript>or any other tag, text; - a property without
--properties, and a function among properties; - a blank line between two properties (a blank line between two functions does not);
- a member written on a line it shares with something else;
- a member whose order cannot be read with confidence: an
accessthat is not a plain word, a modifier and an attribute that disagree, an@accesstag in its doc comment, a property without one plain name; - any component in which the parse did not understand a region (reported
as
path:line: not arranged: reason).
Arranging never formats: it moves the members' text as written, and the
lines between them stay where they were. Run it as its own step, each
in its own commit, and list both commits in .git-blame-ignore-revs:
cfformat arrange -w src && git commit -am "arrange members"
cfformat -w src && git commit -am "format"
Arrange first, then format: formatting the arranged text realigns what the
move put side by side (aligned properties, for one). cfformat arrange --check in CI keeps the order once it is in place.
At runtime, function order does not change what a component does: its
functions exist before its pseudo-constructor runs. It does change the
order of getMetadata().functions, so a test suite that depends on the
order of its tests will see them run in another order. Property order is
visible to getMetadata().properties, to ORM mappings (column order) and
to serialisation, which is why --properties is opt-in.
--git formats the files git reports as changed, in place of PATHS:
cfformat --git staged -w # the staged .cfc and .cfs files
cfformat --git unstaged --check # the changed and untracked ones that would change
cfformat --git all -w --cfm # both, .cfm files included
staged: the files added, copied, modified or renamed in the index since the last commit.unstaged: the files modified in the working tree since the index, and the untracked files that are not ignored.all: both.
The selection is the whole repository, from whichever directory it runs
in; a file under the current directory is printed relative to it. It keeps
what a directory walk keeps — .cfc and .cfs files, and .cfm files
with --cfm — and skips a file that is gone from the working tree. No file
selected is not an error. Without -w, --check or --diff it reports
like a directory. git must be on PATH; outside a repository the run
fails with git's message (exit 2).
-w formats the working tree's copy and never runs git add (CommandBox's
git staged did): a pre-commit hook that wants the commit formatted adds
the files it wrote.
#!/bin/sh
# .git/hooks/pre-commit
cfformat --git staged --checkAny other list of files goes to --files-from. git diff --name-only
prints paths relative to the repository root, so a pipe like this one works
from the root only:
git diff --name-only --diff-filter=ACMR -z main... -- '*.cfc' '*.cfs' | cfformat --files-from - --check
| Code | Meaning |
|---|---|
| 0 | nothing to change, or every change written |
| 1 | --check or --diff: a file would change |
| 2 | a usage error, a file that cannot be read, is not UTF-8 or cannot be written, a failed write to stdout, a settings error, an internal error |
2 wins over 1. A file that fails is reported (path: message) and the run
goes on with the others. cfformat arrange exits the same way.
-
What cfformat does not understand prints as written. A region the parser cannot read — a statement in a script, a tag body in a template — is printed as written (only its line endings follow
newline), everything around it is formatted, and stderr says where:path:line: not formatted: <reason>(an unclosed block,a stray closer,an unmatched run,nesting past the limit). The exit code does not change. Besides genuine errors (an unclosed<cfif>in a view partial), valid code that does this today:- a function parameter named
required(required required=true); varinside an expression (while ((var x = next()) != -1), Lucee);- an arrow function whose body starts with
returnwithout braces ((s, i) => return {…}); - quoted named arguments with
:(query('a': […], 'b': […])); - an array literal as a script
propertyattribute value (property string p preanno=["a", "b"];); - a CF tag inside an unquoted attribute value
(
<option value="#x#" <cfif y>selected=true</cfif>>).
- a function parameter named
-
CF tags inside HTML tags print bare. A CF tag opened or closed inside an HTML tag's attributes (
<a <cfif x>class="on"</cfif> href="…">,class="<cfif …>active"</cfif>") or inside astyle="…"attribute, or a<cfoutput>opened inside a<style>block and closed after it, is paired correctly but printed where it is, without indenting what it wraps. -
<script>/<style>blocks holding CFML tags are not formatted: a block is formatted when itstypeis one the formatter knows (JavaScript, a module, JSON, CSS) and it holds no CFML, or — JavaScript and CSS inside<cfoutput>— only#expr#and##. A CF tag or a tag comment inside (a<cfif>body may hold part of a statement), a#expr#that prints over more than one line, nothing but whitespace around the#expr#(<style>#css#</style>), or a JSON block holding#expr#or##makes the block print as written, with no warning. A block oxc cannot parse, or whose#expr#would not come back as they went, prints as written with a warning (path:line: islands.js: <message>, such aswhat precedes the #…# on line 12 changed from `(` to nothing)."islands.interpolated": falseleaves every block holding#expr#or##as written, with no warning, while blocks with no CFML are still formatted. -
Some blocks keep their text line for line. Formatting never moves a line that starts inside a multi-line string: in a formatted
<script>, the lines inside a template literal or a string continued with\keep their columns. So do the lines of a comment the formatter leaves as written: in a<style>, every/* … */comment's lines after the first, as with Prettier; in a<script>, those of one whose lines do not all start with*. A block printed as written is otherwise shifted as a whole so that no line sits left of where its body belongs (one level inside its tag, or at the tag's indent with"tags.islands.indent": false), and never to the left, except:- a
<script>/<style>whosetypethe formatter does not know (text/plain,text/x-template), holds CFML (type="#kind#"), or whose attributes CFML can emit (<script #attrs#>) is data: every line of its body is kept, blank lines at its start and end and trailing spaces included, and the closing tag follows the last line directly, whateverislands.*or--no-islandssay. Only the CF tags and#…#inside it are formatted, in place. - a
<cfquery>, or a block printed as written (holding CFML, refused, or under"islands.*": "off"), whose text may hold a string spanning lines — for SQL, a string, quoted identifier or dollar quote that spans a line or that the formatter cannot see closed — keeps each line's text, indentation and trailing spaces, and is not shifted or re-indented. Blank lines at the start and end of its body are dropped, as are the last line's trailing spaces, and the closing tag starts its own line.
In both cases, and in a CFML string spanning lines, the line breaks themselves follow the
newlinesetting like the rest of the file: a CRLF source formatted with"newline": "\n"(the default on Linux and macOS) comes out with LF inside these blocks and strings too. - a
-
Very large or deeply nested embedded code prints as written. A
<script>/<style>block larger than 256 KB or nested more than 500 levels deep is not formatted: it prints as written with a warning (path:line: islands.js: nested 2003 levels deep, over the limit of 500). A formatted block whose template-literal lines cannot be found again prints as written with aninternal errorwarning rather than re-indented. How the nesting is counted, and the stack it needs, are in the developer README. -
A file is formatted in memory, whole. Peak memory grows with the file: about 18 MB for a 175 KB component, and up to about 200 times the source for generated, token-dense script (1 GB for 5 MB of statements). The island cache holds every distinct
<script>/<style>block of a run until it ends. The developer README has the numbers. -
UTF-8 only. A file that is not valid UTF-8 is an error and is never written; there is no
--encodingand no transcoding. -
Globs are
globset's:*(not across/),**,?,[…]and{a,b}. A path that exists is never read as a glob. -
Not on crates.io (see Install).
The cfformat crate is also a library: cfformat::format_source(src, mode, &options) formats a string, and format_with takes a context (the file's
path, for island configuration, and a shared Islands cache) and returns the
warnings too — format_source drops them. Options come from
Options::from_json or options::Discovery, which resolves .cfformat.json
files as the CLI does. The library never reads or writes the source file,
prints, or starts a thread pool; set islands.config to "off" for a run
that reads no configuration file either. The cfparse and cfdoc types
the API takes or returns (Mode, Tree, Doc, …) are re-exported from
cfformat; to parse a tree or print a document yourself, depend on those
crates too. cfformat::arrange(src, mode, &ArrangeOptions) is
cfformat arrange over a string. crates/cfformat/README.md is the full
API.
Depend on it by path or git, and copy the workspace's oxc_allocator line
from [patch.crates-io] into your own root Cargo.toml — Cargo reads patches
only from the root of the build, and without it the build fails with
error[E0308]: mismatched types in oxc_formatter_css:
[patch.crates-io]
oxc_allocator = { git = "https://github.com/oxc-project/oxc", rev = "2bd08ebe8f36fcf1954a675ffdeb4c6d0129f609" }The workspace's other crates are cfformat-cli (the cfformat command
line, over this library), cfparse (the CFML parse tree), cfdoc (a port
of Prettier's document printer), cfvet (below) and cfcli
(the file walking and reading the two binaries share).
cfvet is not a general linter: it is two checks that need a parse tree
to get right, in a second binary on the same one. missing-var reports
every unscoped write inside a function — in classic CFML it lands in the
variables scope — and unevaluated-call every #…# that calls a
function outside <cfoutput>, which the page shows as written. Every
release carries it next to cfformat, under the same asset
names with the cfvet_ prefix (cfvet_linux_x86_64, cfvet_macos,
cfvet_windows_x86_64.exe, …), or it is built from source:
cargo install --locked --git https://github.com/jcberquist/cfformat cfvet
cargo install --locked --path crates/cfvet # from a checkout
$ cfvet src
src/Report.cfc:12:9: missing-var: `total` is written without `var` or a scope in function `build`; it lands in the variables scope
1 report in 14 files (63 functions checked)
A cfvet-ignore comment silences its own line and the next. What counts
as a write and a declaration, where a #…# is live, the flags and the exit codes are in
crates/cfvet/README.md.
MIT: LICENSE. crates/cfdoc is a port of Prettier's document
printer and keeps Prettier's MIT notice,
crates/cfdoc/LICENSE-PRETTIER.