BoxLang 🚀 A New JVM Dynamic Language Learn More...
Pure CFML library for parsing and emitting TOML 1.0.0. Cross-engine compatible across Adobe ColdFusion 2016+, Lucee 5+, and BoxLang 1+.
cfTOML is a single CFC file. Drop cfTOML.cfc into your
project and use it. No external dependencies, no Java JARs, nothing to compile.
Source: https://github.com/JamoCA/cfTOML
Option 1: Download a release ZIP
Go to https://github.com/JamoCA/cfTOML/releases,
download the latest release, and unzip it anywhere on your CFML path.
Only cfTOML.cfc is required at runtime; the rest of the
files are tests, examples, and tooling.
Option 2: Clone the repository
git clone https://github.com/JamoCA/cfTOML.git
Then copy cfTOML.cfc to your project, or add a CFML
mapping pointing at the clone directory. In your Application.cfc:
this.mappings["/cftoml"] = expandPath("./lib/cfTOML");
Then instantiate:
parser = new cftoml.cfTOML();
Option 3: Git submodule
If your project is in git, add cfTOML as a submodule:
git submodule add https://github.com/JamoCA/cfTOML.git lib/cfTOML
git submodule update --init --recursive
Pin to a specific release tag for reproducible builds:
cd lib/cfTOML
git checkout v1.2.0
cd ../..
git add lib/cfTOML
git commit -m "pin cfTOML to v1.2.0"
If you already use CommandBox / ForgeBox:
box install cftoml
Or in your box.json:
"dependencies": {
"cftoml": "^1.2.0"
}
parser = new cfTOML();
// Parse from string
data = parser.tomlDeserialize(fileRead("config.toml"));
// Or read directly from file
data = parser.tomlReadFile("config.toml");
writeOutput(data.server.host); // "10.0.0.1"
// Emit
toml = parser.tomlSerialize(["server": ["host": "10.0.0.1", "port": 8080]]);
// Or write directly to file
parser.tomlWriteFile("config.toml", data);
cfTOML supports both TOML 1.0.0 (default) and TOML 1.1.0 (opt-in).
Pass spec = "1.1.0" to parse a 1.1.0 document:
var data = tomlDeserialize(content, ["spec": "1.1.0"]);
By default, the emitter produces 1.0.0-compatible output even when the parser is in 1.1.0 mode. This means you can safely parse 1.1.0 inputs and re-emit them for consumers that only support 1.0.0. To emit 1.1.0-only constructs, set the corresponding option:
| Construct | Option |
|---|---|
| Multi-line inline tables | inlineMultiline =
true (switches when single-line exceeds 80 characters or
contains multi-line strings) |
\e and \xHH
escapes | useExtendedEscapes = true
|
Omit :00 seconds in
datetimes | omitZeroSeconds = true
|
All-digit bare keys (e.g. 1234 =
"value") | useBareDigitKeys = true
|
Setting any of these knobs while spec =
"1.0.0" throws cfTOML.ConfigError.
cfTOML is tested against the BurntSushi/toml-test corpus (pinned at toml-test v1.5.0, manifest-filtered per spec) for both spec versions. On engines with native case-sensitive struct support (Adobe ColdFusion 2021, 2023, 2025):
On engines where case-sensitive ordered structs are not available or
whose dot-notation accessor uppercases (Adobe CF 2016, Lucee 5/6/7,
BoxLang 1.13), the conformance numbers are 180/182 + 371/371 (1.0.0)
and 185/187 + 361/361 (1.1.0). The 2-test difference is the BurntSushi
case-sensitive test (valid/key/case-sensitive.toml) and
valid/inline-table/key-dotted-4.toml, which require keys
differing only in case to round-trip distinctly. See the "Known
limitations" section for context.
The unit suite passes 634/634 on every supported engine. Every valid
TOML input parses into the correct typed structure, and every invalid
input is rejected with a cfTOML.*-prefixed exception.
Strict-mode coverage includes control characters in strings,
leading-zero integers, case-sensitive
true/false/inf/nan,
separator state machines for arrays and inline tables, trailing-token
rejection after statements, datetime offset range validation,
multiline-string line-continuation rules, bare-key segment validation
in table headers, table-conflict tracking for dotted-key intermediates
and array-of-tables, inline-table immutability, and strict UTF-8
byte-level validation.
(Run tests/conformance/run-conformance.cfm?spec=1.0.0 or
?spec=1.1.0 to reproduce.)
{ ... }.\e escape - decodes to U+001B (ESC).\xHH escape - exactly two hex digits, decodes to U+00HH.1979-05-27T07:32 is
equivalent to 1979-05-27T07:32: 00.1234 = "value" is
permitted; the key is always a string.tomlDeserialize(toml, options)
Parse a TOML string and return an ordered struct.
toml (required string): TOML source.options (struct, default [:]): see Options below.Returns: ordered struct.
tomlReadFile(path, options)
Read a TOML file (UTF-8) and parse it.
path (required string): absolute or webroot-relative
file path.options (struct): same as tomlDeserialize.Returns: ordered struct.
tomlSerialize(data, options)
Serialize an ordered struct to a TOML 1.0 string.
data (required struct): the data to serialize.options (struct): see Options below.Returns: TOML string.
tomlWriteFile(path,
data, options)
Serialize and write to a UTF-8 file (no BOM).
path (required string): output file path.data (required struct): data to write.options (struct): same as tomlSerialize.Returns: void.
| Key | Type | Default | Purpose |
|---|---|---|---|
strict
| boolean | true
| Reject any spec violation. false allows
trailing commas in inline tables. |
dateTimeReturn
| string | "cfdate"
| How datetime values are returned:
"cfdate" (CFML date object, milliseconds
preserved; offset datetimes are converted to the server's local
timezone so the instant is preserved),
"iso8601" (raw RFC 3339 string),
"javatime" (java.time.OffsetDateTime /
LocalDateTime / LocalDate / LocalTime). |
int64Mode
| string | "double"
| Integer return type: "double"
(CFML number, max safe 2^53), "javalong"
(Java Long, full int64 range), "string"
(decimal digit string for arbitrary range). |
indent
| string | ""
| Emit-side: indent string under nested
[header] blocks. "" = flat
output, "\t" = one-tab indent per depth. |
sortKeys
| boolean | false
| Emit-side: alphabetize keys. Default preserves insertion order. |
inlineThreshold
| numeric | 0
| Emit-side: if >0, top-level structs with
<=N scalar-only keys emit as inline tables
instead of [header] blocks. |
onNull
| string | "skip"
| Emit-side null handling: "skip"
(omit key), "throw" (cfTOML.TypeError),
"emptyString" (key = ""). |
queryAsArrayOfTables
| boolean | false
| Emit-side: when true, CFML query objects
emit as array-of-tables. When false, query values
throw cfTOML.TypeError. |
TOML defines four distinct datetime types: offset, local, date-only,
time-only. CFML's native date type doesn't cleanly distinguish these,
so the parser offers three return modes via dateTimeReturn:
"cfdate"
(default): All four types return CFML date objects. Offset
datetimes (including Z) are converted to the server's
local timezone via java.time.ZoneId.systemDefault() so
the returned datetime represents the same INSTANT as the source;
wall-clock varies by server zone, the instant does not. Local
datetimes (no offset in source) keep their wall-clock verbatim.
Fractional seconds are truncated to milliseconds (3 digits) and
applied with dateAdd("l", ms, dt) so
precision survives on CF2016+ where createDateTime()
lacks a millisecond argument. Ergonomic for code that uses CFML's
date functions."iso8601"
: All four types return the original RFC 3339 string
verbatim. Lossless. Best for config-file use cases."javatime"
: Returns typed java.time.* objects
(OffsetDateTime, LocalDateTime,
LocalDate, LocalTime). Lossless,
type-distinct, and zone-aware. Requires Java method calls on the
caller side.When serializing CFML to TOML, root-level scalars and arrays get
written before the first [header] block. This is the TOML
grammar at work, not cfTOML being clever.
In TOML, every bare key after a [header] belongs to that
header's table until the next header shows up. So a CFML struct like
{ "server": { "host": "..." },
"tags": [...] } cannot serialize as:
[server]
host = "..."
tags = [...]
That would round-trip back as server.tags, which is the
wrong shape. cfTOML writes tags above
[server] so the original structure survives:
tags = [...]
[server]
host = "..."
Every TOML emitter (Rust, Python, Go) does the same. The data
round-trips exactly. Only the textual order of keys at the root
differs from the source. To keep the source order more visible, pass
inlineThreshold so small structs stay inline
(server = { host = "..." }) and live at the
root instead of becoming [header] blocks.
The library throws four distinct exception types:
cfTOML.ParseError - syntax violations.
detail is JSON with line,
column, offset, snippet,
expected keys.cfTOML.TypeError - value-shape mismatches (redefining a
table as a scalar, unflagged query on emit, etc.).cfTOML.DuplicateKeyError - key redefinition.cfTOML.OverflowError - int64 out of range when
int64Mode = "double" and strict = true.| Engine | Status |
|---|---|
| Adobe ColdFusion 2016 | Supported |
| Adobe ColdFusion 2021 | Supported |
| Adobe ColdFusion 2023 | Supported |
| Adobe ColdFusion 2025 | Supported |
| Lucee 5 | Supported |
| Lucee 6 | Supported |
| Lucee 7 | Supported |
| BoxLang 1+ | Supported |
See tools/run-engine-matrix.ps1 to run the full unit
test suite across all engines (requires CommandBox).
The library is verified against the BurntSushi/toml-test
conformance corpus for both TOML 1.0.0 and 1.1.0 across the full
engine matrix (Adobe CF 2016/2021/2023/2025, Lucee 5/6/7, BoxLang
1.13). Adobe CF 2021+ passes 100% valid and 100% invalid
(553/553 for 1.0.0 and 548/548 for 1.1.0), zero runtime
errors. CF 2016, Lucee, and BoxLang pass 551/553 (1.0.0) and 546/548
(1.1.0); the two-test gap on each spec is the case-sensitive-key tests
that require an engine-native case-sensitive struct with working
dot-notation. See tests/conformance/README.md for
instructions on fetching and running the conformance suite.
All malformed inputs throw a cfTOML.*-prefixed exception
(ParseError, TypeError,
DuplicateKeyError, OverflowError, or
ConfigError). Engine-native exceptions from
parseDateTime(), java.time.*.parse(), or
chr() on out-of-range Unicode no longer leak through.
cfTOML-edit in a future release).ordered-casesensitive struct
returns a wrapper whose dot-notation accessor uppercases the lookup
key, so a key stored as section cannot be read via
data.section). On those engines the parser falls back
to the case-insensitive ordered [:] literal, so a
top-level pair of keys differing only in case (section
and sectioN) collide. Bracket-notation access
(data["section"]) works regardless.int64Mode = "double" (the default). Use
"javalong" or "string"
for full 64-bit range.DATETIME_LOCAL
(zone-naive). For specific offsets, pass a
java.time.OffsetDateTime instance.java.time.* datetime values.After cloning, start a local CFML server and open
demo.cfm in a browser. The demo shows the engine and
version it detected, lets you paste or pick a TOML payload to parse,
lets you pick a pre-built CFML object (or paste JSON) to serialize,
and reports both single-conversion time and the number of conversions
per second.
If cf_dump by
kwaschny is available, the demo also renders the parsed object
through it below the JSON output. The native
<cfdump> tag collapses line breaks inside strings
and omits data types on scalars, so cf_dump gives a clearer picture of
what came out of the parser.
git clone https://github.com/JamoCA/cfTOML.git
cd cfTOML
box server start
Then open http://localhost:8128/demo.cfm
git clone https://github.com/JamoCA/cfTOML.git
cd cfTOML
box server start
curl http://localhost:8128/tests/runner.cfm
To run the conformance suite:
powershell tests/conformance/fetch-corpus.ps1
curl http://localhost:8128/tests/conformance/run-conformance.cfm
To run the engine matrix:
powershell tools/run-engine-matrix.ps1
MIT. See LICENSE.
James Moberg [email protected]
toml instead of cftoml), so both badges showed "package not found" on the rendered README. The badge URLs and the linked package pages now use the correct cftoml slug.spec option. Default remains "1.0.0"; pass { spec: "1.1.0" } to enable. New 1.1.0 parser features:
{ ... })\e escape sequence (U+001B)\xHH escape sequence (exactly two hex digits, U+00HH)HH:MM accepted, synthesized to HH:MM: 00)inlineMultiline, useExtendedEscapes, omitZeroSeconds, useBareDigitKeys. Default behavior even in spec = "1.1.0" mode is 1.0.0-compatible output unless these knobs are set, so callers can safely parse 1.1.0 inputs and re-emit them for 1.0.0 consumers.cfTOML.ConfigError raised when a 1.1.0-only emitter knob is set under spec = "1.0.0".tests/conformance/run-conformance.cfm?spec=1.1.0.structNew("ordered-casesensitive") (and Lucee's linked-casesensitive) at init time and uses it for the parsed data tree, so [section] and [Section] no longer collide. CF 2016/2018 keep the case-insensitive [:] fallback.cfTOML.*-prefixed exception. Datetime range violations (out-of-range hour, mday, month, etc.), Unicode escape out-of-range and surrogate-range violations, and non-UTF-8 file encodings previously surfaced as engine-native Expression, Application, or Template exceptions. They are now wrapped as cfTOML.ParseError.STRING_BASIC and STRING_LITERAL). Previously, a key like "foo.bar" = 1 or {"key" = "value"} parsed as a quoted-value mistake. TOML 1.0.0 has always allowed quoted keys; the parser now matches the spec."name".'last' = "Dent" parse correctly."", '') survive dotted-path splitting and table-header parsing. [""], ''.x, and "".'' = "x" are valid TOML 1.0.0 patterns.[ j . "ʞ" . 'l' ] produces ["j", "ʞ", "l"], not ["j", "ʞ ", " 'l'"]).inf, nan, true, false are accepted as bare keys when they appear in key position (the lexer greedily emits FLOAT/BOOL for them, but the parser now treats those token types as valid key segments).- (-key = 1, [-], [---] parse correctly).10e3 and 3.14 are accepted as keys when at start-of-statement (3.14 = "pi" parses as dotted key 3.14).2000-datetime = "x" now lexes as a single bare key rather than splitting into INT(2000) and KEY(-datetime).\u and \U escape decoders explicitly reject the surrogate range (U+D800..U+DFFF) and (for \U) codepoints above U+10FFFF. High codepoints (U+10000+) now produce correct UTF-16 surrogate-pair output via Character.toChars().1234 = "value") are now accepted in TOML 1.0.0 mode. The spec note says "bare keys are allowed to be composed of only ASCII digits, e.g. 1234, but are always interpreted as strings"; an earlier internal misreading had gated this behind 1.1.0.\u, \U, and \xHH escape decoders use Java's Character.toChars() for the resulting codepoint instead of CFML's chr(). chr(0) on Adobe CF returns an empty string, which silently dropped the NUL character from inputs like "�"; the Java path preserves the full Unicode scalar across the entire range U+0000..U+10FFFF.structNew("ordered-casesensitive") (with engine fallbacks) instead of [:], so test data with keys differing only in case no longer merges before comparison.com.fasterxml.jackson.databind.ObjectMapper) instead of CFML's deserializeJSON(), so the expected struct stays case-sensitive too.java.lang.String class before the numeric-looking-string fallback, so a quoted TOML scalar like version = "4" is reported as a string instead of being silently promoted to integer.java.lang.Double.toString(d) instead of CFML's toString(), which on Adobe CF truncates to roughly 11 significant digits. Full IEEE-754 precision now survives serialization for comparison.tests/conformance/fetch-corpus.ps1) now applies the toml-test files-toml-1.0.0 manifest to the 1.0.0 corpus, matching how it already filtered the 1.1.0 corpus. The previous unfiltered copy left 1.1.0-only valid tests in the 1.0.0 set, which skewed the 1.0.0 numbers downward. Re-run the fetcher after upgrading to pick up the filter.dateTimeReturn = "cfdate" (default) is no longer zone-lossy on offset datetimes. Previously the parser dropped the zone and returned the raw wall-clock from the source, so 1979-05-27T07:32: 00Z and 1979-05-27T07:32: 00-08:00 both produced the CFML datetime 1979-05-27 07:32: 00 regardless of where the server ran. The parser now converts offset datetimes to the server's local zone via java.time.OffsetDateTime.atZoneSameInstant(ZoneId.systemDefault()), so the returned CFML datetime represents the same instant as the source. Local datetimes (no offset in source) still keep their wall-clock verbatim because there's no zone to interpret. Code that relied on the old zone-dropping behavior should switch to dateTimeReturn = "iso8601" (raw RFC 3339 string) or "javatime" (zone-aware java.time.* objects).cfdate mode. Sources like 1979-05-27T07:32: 00.456 and 07:32: 00.7Z now carry their milliseconds through to the returned CFML date via dateAdd("l", ms, dt). Beyond three digits the fraction is truncated; shorter fractions right-pad (.7 -> 700ms). CFML's native createDateTime() lacks a millisecond argument on CF2016, which is why the dateAdd workaround is used uniformly.year/month/day/hour/minute/second accessors rather than dateTimeFormat() masks because CFML's mask vocabulary is engine-dependent (Adobe CF and Lucee: mm is month, nn is minute; BoxLang: mm is minute, nn is unknown). Component-based emission is identical on every supported engine.1 rather than 1.0). On Lucee, every numeric literal is stored as java.lang.Double regardless of whether the source was 1 or 1.0, so emit-side distinction between integer and float is not portable. Callers who need a TOML float in the output should pass a non-whole value or use int64Mode = "string" with a decimal-shaped string.formatter.format(value) rather than value.format(formatter) on java.time.* objects. BoxLang shadows the CFML format(value, mask) built-in over the Java instance method, so the second form is rejected with a "format mask must be string" error. Calling the formatter as the receiver bypasses the shadowing on every engine.java.io.FileInputStream + InputStreamReader with a strict UTF-8 decoder rather than ByteBuffer.wrap(byte[]) + decoder.decode(). BoxLang cannot dispatch the static wrap() method on the abstract ByteBuffer class via createObject; the InputStreamReader path uses instance methods only and works everywhere.if/else block instead of a ternary, and the valid/invalid test loops are extracted into named functions. BoxLang's bytecode generator reports "Bad return type" on a mix of template-scope ternaries and large <cfif>-equivalent then-blocks; the function-and-block refactor resolves the verification failure without affecting other engines.tests/Application.cfc and tests/conformance/Application.cfc compute the /cfTOML mapping target from getDirectoryFromPath(getCurrentTemplatePath()) & ".." instead of expandPath(".."). BoxLang resolves expandPath("..") from a sub-template differently than Adobe CF and Lucee, putting the mapping one directory too high and making new cfTOML.cfTOML() fail to resolve.Measured across the full engine matrix (Adobe CF 2016/2021/2023/2025, Lucee 5/6/7, BoxLang 1.13) against the BurntSushi/toml-test corpus (pinned at toml-test v1.5.0, manifest-filtered per spec):
The 2 errors on the case-insensitive engines are valid/key/case-sensitive.toml and valid/inline-table/key-dotted-4.toml, which require keys differing only in case to round-trip distinctly. Adobe CF 2021+ provides structNew("ordered-casesensitive") that preserves case under dot-notation access; older Adobe and the other engines either lack the struct type or implement it in a way that breaks dot-notation, so the parser falls back to the case-insensitive ordered [:] literal. The unit suite passes 634/634 on every supported engine.
Every valid TOML input parses into the correct typed structure, and every invalid input is rejected with a cfTOML.*-prefixed exception. Strict-mode work that closed the remaining invalid-side gap: control-character rejection across the five string/comment lexer sites, leading-zero integer and float rejection, case-sensitive true/false/inf/nan, array and inline-table separator state machines (rejecting [,], [1,,2], [1 2], {x=3 y=4}), trailing-token rejection after statements ([error] this = "...", a = 1 b = 2), datetime offset hour/minute range validation, multiline-string line-continuation strictness (\<space>x is invalid), bare-key segment validation in table headers (rejecting [name=bad], [a[b], [key#group], [invalid key]), table-conflict tracking for dotted-key intermediates ([fruit]; apple.color="red"; [fruit.apple] now throws), static-array vs array-of-tables distinction in header walks, KV-pair dotted-key rejection when walking through AoT or another header's explicit table, inline-table immutability (rejecting {a = {b=1}, a.c = 2}), and strict UTF-8 byte-level validation in the conformance runner via java.nio.charset.StandardCharsets.UTF_8.newDecoder().onMalformedInput(REPORT).
Initial release. Full TOML 1.0.0 parser and emitter with round-trip semantic equivalence.
decodeBasicStringEscapes, decodeMultiLineBasicEscapes, parseIntegerLexeme, parseRFC3339).[table] headers, dotted keys, duplicate-key detection, table-redefinition detection. tomlDeserialize and tomlReadFile wired end-to-end.[, ], ,, {, }), bracket-depth tracking, array parsing, inline tables (with immutability), array-of-tables (with conflict detection), quoted-segment paths.tomlSerialize, tomlWriteFile). Three-pass table walk (scalars, nested [a.b], AoT [[a.b]]). Options: sortKeys, indent, inlineThreshold, onNull, queryAsArrayOfTables. Round-trip semantic equivalence verified.tomlDeserialize(toml, options) - parse string to ordered structtomlReadFile(path, options) - read and parse UTF-8 TOML filetomlSerialize(data, options) - serialize struct to TOML stringtomlWriteFile(path, data, options) - serialize and write to fileAdobe ColdFusion 2016/2021/2023/2025, Lucee 5/6/7, BoxLang 1+.
$
box install cftoml