ccl
Categorical Configuration Language (CCL) for Gleam.
CCL parses into an opaque Document that retains the original source, key
order, comments, and indentation. Unedited documents round-trip to their
original text; edits write back in place and preserve the surrounding
structure. Document stays opaque so the internal entry
representation can evolve without breaking the public API.
import ccl
pub fn main() {
let source = "/= the server block\nserver =\n host = localhost\n port = 8080\n"
case ccl.parse(source) {
Ok(doc) ->
case ccl.set_int(doc, ["server", "port"], 9090) {
Ok(updated) -> ccl.to_string(updated)
// -> "/= the server block\nserver =\n host = localhost\n port = 9090\n"
Error(error) -> handle_edit_error(error)
}
Error(error) -> handle_parse_error(error)
}
}
Types
The indentation baseline used for top-level entries.
pub type Baseline {
StripToplevelIndent
PreserveToplevelIndent
}
Constructors
-
StripToplevelIndentThe top-level baseline is always column 0, matching the OCaml reference. The default.
-
PreserveToplevelIndentThe parser detects the top-level baseline from the first content line, so uniformly indented documents parse as if they started at column 0.
Which strings get_bool and as_bool accept.
pub type Booleans {
BooleanStrict
BooleanLenient
}
Constructors
-
BooleanStrictOnly
trueandfalse, case-insensitively. The default. -
BooleanLenientAlso accept
yes/no,on/off, and1/0, case-insensitively.
Errors that can occur while parsing CCL and decoding it with a dynamic decoder.
pub type DecodeError {
DecodeParseError(ParseError)
DecodeDynamicError(List(decode.DecodeError))
}
Constructors
-
DecodeParseError(ParseError)The input did not parse as CCL.
-
DecodeDynamicError(List(decode.DecodeError))The input parsed successfully, but the supplied decoder did not match the data.
How the parser locates the = delimiter on a line that contains more
than one.
pub type Delimiter {
FirstEquals
PreferSpaced
}
Constructors
-
FirstEqualsAlways split on the first
=in the line. -
PreferSpacedPrefer a spaced
=delimiter; when the line has no spaced form, split on the first=. Lets keys contain=, such as URLs with query parameters. The default.
A parsed CCL document.
Documents are opaque so CCL can preserve round-trip invariants while the
internal entry representation changes. A document keeps the Options it
was parsed with, so reads and edits stay consistent with the parse.
pub opaque type Document
Errors that can occur while editing a document.
Variants are part of the stable public API. Adding, removing, or renaming a variant is a breaking change.
pub type EditError {
EmptyKeyPath
InvalidKeySegment(segment: String)
InvalidCommentText
MissingEditKey(key: List(String))
KeyConflict(key: List(String))
InvalidValue
}
Constructors
-
EmptyKeyPathEdit paths must contain at least one key segment.
-
InvalidKeySegment(segment: String)A key segment cannot be emitted as CCL. Segments may not be empty, contain a newline or an
=, or have leading or trailing whitespace, since the parser would not read the result back as the same key. -
InvalidCommentTextComments must be a single line.
-
MissingEditKey(key: List(String))The edit requires an existing key, but no value exists at that key path.
-
KeyConflict(key: List(String))Descending through the path would have to replace an existing terminal value with a nested block.
-
InvalidValueThe supplied value cannot be represented in the requested edit context. A
set_stringvalue containing a newline is the common case; useset_valuewith anObjectValueorListValuefor multi-line data.
A flat key/value entry, as produced by CCL’s first parsing pass.
The parser trims surrounding whitespace from keys. Values keep their
internal structure, so a nested block’s value is the multi-line text under
it, indentation included. Two keys are special: "" marks a list item written
as = value, and "/" marks a comment written as /= text.
pub type Entry {
Entry(key: String, value: String)
}
Constructors
-
Entry(key: String, value: String)
CCL value kinds used in typed read errors.
Variants are part of the stable public API. Adding, removing, or renaming a variant is a breaking change.
pub type ExpectedType {
ExpectedString
ExpectedInt
ExpectedBool
ExpectedFloat
ExpectedList
ExpectedObject
}
Constructors
-
ExpectedString -
ExpectedInt -
ExpectedBool -
ExpectedFloat -
ExpectedList -
ExpectedObject
Errors that can occur while reading typed values from a document.
Variants are part of the stable public API. Adding, removing, or renaming a variant is a breaking change.
pub type GetError {
KeyNotFound(key: List(String))
WrongType(key: List(String), expected: ExpectedType)
}
Constructors
-
KeyNotFound(key: List(String))No value exists at the requested key path.
-
WrongType(key: List(String), expected: ExpectedType)A value exists at the requested key path, but it has a different CCL shape, or its text does not parse as the requested type.
How the parser treats CRLF line endings.
pub type LineEndings {
NormalizeCrlf
PreserveCrlf
}
Constructors
-
NormalizeCrlfRewrite every
\r\nto\nbefore parsing. The cross-platform default. -
PreserveCrlfKeep
\rcharacters exactly as they appear in the source.
Whether a single value can stand in for a one-element list.
pub type ListCoercion {
CoercionDisabled
CoercionEnabled
}
Constructors
-
CoercionDisabledReading a list from a terminal value is a
WrongTypeerror. The default. -
CoercionEnabledA terminal value reads as a one-element list.
The order in which repeated empty-key entries are collected.
pub type ListOrder {
InsertionOrder
LexicographicOrder
}
Constructors
-
InsertionOrderElements keep their source order. The default.
-
LexicographicOrderElements sort lexicographically by their terminal text.
CCL’s canonical recursive model, mirroring the OCaml reference’s
Fix of t KeyMap.t.
Terminal strings become keys pointing at the empty model, duplicate keys
merge, and every leaf is Model([]). Unlike Value, the model is
order-agnostic by construction; ordering belongs to the typed projections.
pub type Model {
Model(List(#(String, Model)))
}
Constructors
-
Model(List(#(String, Model)))
Parsing, reading, and list-building behaviour for a document.
Options is opaque so new settings can be added without breaking callers.
Start from default_options and pipe through the with_* builders:
let options =
ccl.default_options()
|> ccl.with_delimiter(ccl.FirstEquals)
|> ccl.with_booleans(ccl.BooleanLenient)
ccl.parse_with(source, options)
pub opaque type Options
Errors that can occur while parsing CCL input.
Variants are part of the stable public API. Adding, removing, or renaming a variant is a breaking change.
CCL’s grammar accepts any text, so parse on a String does not currently
fail. ParseError exists so the library can report byte-level and future
strict-mode diagnostics without a breaking change; parse_bytes already
returns InvalidEncoding.
pub type ParseError {
InvalidEncoding
InvalidSyntax(kind: SyntaxErrorKind, offset: Int)
}
Constructors
-
InvalidEncodingThe raw bytes are not valid UTF-8 text.
-
InvalidSyntax(kind: SyntaxErrorKind, offset: Int)CCL syntax was invalid at a byte offset.
A one-based source position.
Positions are opaque so later versions can add more source-location detail
without a change to the public constructor shape. Use position_line and
position_column to inspect one.
pub opaque type Position
Stable categories for CCL syntax errors.
Variants are part of the stable public API. Adding, removing, or renaming a variant is a breaking change.
pub type SyntaxErrorKind {
ExpectedKey
ExpectedValue
InvalidCcl
}
Constructors
-
ExpectedKeyThe parser expected a key before the
=delimiter. -
ExpectedValueThe parser expected a value after the
=delimiter. -
InvalidCclCCL syntax was invalid, but the parser does not expose a narrower stable category.
How the parser treats tab characters.
pub type Tabs {
TabsAsWhitespace
TabsAsContent
}
Constructors
-
TabsAsWhitespaceSpaces and tabs both count as indentation whitespace. The default.
-
TabsAsContentOnly spaces count as indentation; tabs stay in the value as content.
A CCL value.
Variants are part of the stable public API. Adding, removing, or renaming a variant is a breaking change.
ObjectValue exposes its entries as an ordered association list of
#(key, value) pairs, and this shape is stable, so source order survives
a read. Repeated empty keys (= a, = b) collect into a
ListValue stored under the "" key of their enclosing object.
pub type Value {
StringValue(String)
ObjectValue(List(#(String, Value)))
ListValue(List(Value))
}
Constructors
Values
pub fn append_list_item(
doc: Document,
key: List(String),
value: Value,
) -> Result(Document, EditError)
Append an item to the list at a key path, creating the list if the key does not exist yet.
let assert Ok(doc) = ccl.parse("ports =\n = 80\n")
let assert Ok(updated) =
ccl.append_list_item(doc, ["ports"], ccl.StringValue("443"))
ccl.to_string(updated)
// -> "ports =\n = 80\n = 443\n"
pub fn as_bool(value: Value) -> Result(Bool, GetError)
Read a boolean from a Value, accepting only true and false.
Use as_bool_with to apply a document’s Booleans option. See as_string
for the error convention.
pub fn as_bool_with(
value: Value,
options: Options,
) -> Result(Bool, GetError)
Read a boolean from a Value using the given options.
pub fn as_float(value: Value) -> Result(Float, GetError)
Read a float from a Value. See as_string for the error convention.
pub fn as_int(value: Value) -> Result(Int, GetError)
Read an integer from a Value. See as_string for the error convention.
pub fn as_list(value: Value) -> Result(List(String), GetError)
Read a list of terminal strings from a Value, without list coercion.
Use as_list_with to apply a document’s ListCoercion option. See
as_string for the error convention.
pub fn as_list_with(
value: Value,
options: Options,
) -> Result(List(String), GetError)
Read a list of terminal strings from a Value using the given options.
pub fn as_pairs(
value: Value,
) -> Result(List(#(String, Value)), GetError)
Read a block’s entries from a Value, in source order.
See as_string for the error convention.
pub fn as_string(value: Value) -> Result(String, GetError)
Read a string from a Value.
Mirrors get_string, but operates on a Value already obtained from
get. On a type mismatch the error reports an empty key path, since a bare
Value has no path context.
pub fn as_values(value: Value) -> Result(List(Value), GetError)
Read a list of values from a Value, without list coercion.
pub fn as_values_with(
value: Value,
options: Options,
) -> Result(List(Value), GetError)
Read a list of values from a Value using the given options.
pub fn bool_decoder() -> decode.Decoder(Bool)
A decoder for CCL’s boolean text, accepting only true and false.
See int_decoder for why decode.bool does not work here.
pub fn decode(
input: String,
decoder: decode.Decoder(a),
) -> Result(a, DecodeError)
Parse CCL text and run a gleam/dynamic/decode decoder against it.
This is a convenience wrapper around parse_dynamic and decode.run.
import gleam/dynamic/decode
let server_decoder = {
use host <- decode.field("host", decode.string)
use port <- decode.field("port", decode.string)
decode.success(#(host, port))
}
ccl.decode("host = localhost\nport = 8080\n", server_decoder)
// -> Ok(#("localhost", "8080"))
Every CCL terminal value is text, so decode.int, decode.bool, and
decode.float do not match. Use int_decoder, bool_decoder, and
float_decoder for those fields.
pub fn decode_with(
input: String,
options: Options,
decoder: decode.Decoder(a),
) -> Result(a, DecodeError)
Parse CCL text with the given options and run a dynamic decoder against it.
pub fn default_options() -> Options
The default options: CRLF normalised to LF, tabs as whitespace, a stripped top-level indent, spaced-delimiter preference, strict booleans, no list coercion, and insertion-ordered lists.
pub fn entries(doc: Document) -> List(Entry)
Read a document’s flat entries, in source order and before any nesting is expanded.
This is CCL’s first parsing pass: every top-level key = value pair, with
nested blocks left as the raw multi-line text of their value. Use
to_value for the expanded tree.
let assert Ok(doc) = ccl.parse("server =\n host = localhost\n")
ccl.entries(doc)
// -> [ccl.Entry("server", "\n host = localhost")]
pub fn float_decoder() -> decode.Decoder(Float)
A decoder for CCL’s float text. An integer literal decodes as a float.
See int_decoder for why decode.float does not work here.
pub fn get(
doc: Document,
key: List(String),
) -> Result(Value, GetError)
Read a CCL value at a key path.
An empty path returns the whole document as an ObjectValue. Use get
instead of the typed get_* helpers when you need to inspect nested blocks
or lists.
A path segment that is a non-negative decimal indexes into a list, either a
ListValue directly or the list held under an enclosing block’s empty key,
so get(doc, ["ports", "0"]) reads the first item of ports =\n = 80.
let assert Ok(doc) = ccl.parse("server =\n host = localhost\n")
ccl.get(doc, ["server"])
// -> Ok(ccl.ObjectValue([#("host", ccl.StringValue("localhost"))]))
pub fn get_bool(
doc: Document,
key: List(String),
) -> Result(Bool, GetError)
Read a terminal value at a key path and parse it as a boolean.
The document’s Booleans option controls which strings match;
BooleanStrict (the default) accepts only true and false,
case-insensitively.
pub fn get_float(
doc: Document,
key: List(String),
) -> Result(Float, GetError)
Read a terminal value at a key path and parse it as a float.
An integer literal reads as a float, so 2 yields 2.0.
pub fn get_int(
doc: Document,
key: List(String),
) -> Result(Int, GetError)
Read a terminal value at a key path and parse it as an integer.
pub fn get_list(
doc: Document,
key: List(String),
) -> Result(List(String), GetError)
Read a list of terminal strings at a key path.
This reads both a bare list and CCL’s usual named-list shape, where the items live under the empty key of a nested block:
let assert Ok(doc) = ccl.parse("ports =\n = 80\n = 443\n")
ccl.get_list(doc, ["ports"])
// -> Ok(["80", "443"])
A non-list value is a WrongType error unless the document was parsed with
with_list_coercion(CoercionEnabled), which reads it as a single item.
pub fn get_string(
doc: Document,
key: List(String),
) -> Result(String, GetError)
Read a terminal string value at a key path.
pub fn get_values(
doc: Document,
key: List(String),
) -> Result(List(Value), GetError)
Read a list of values at a key path, keeping nested items intact.
This accepts the same list shapes as get_list.
pub fn insert_comment_before(
doc: Document,
key: List(String),
text: String,
) -> Result(Document, EditError)
Insert a comment line immediately before the entry at a key path.
CCL writes comments as /= text. The text may not contain a newline; call
this once per line for a multi-line comment.
let assert Ok(doc) = ccl.parse("port = 8080\n")
let assert Ok(updated) =
ccl.insert_comment_before(doc, ["port"], "the listening port")
ccl.to_string(updated)
// -> "/= the listening port\nport = 8080\n"
pub fn int_decoder() -> decode.Decoder(Int)
A decoder for CCL’s integer text.
Every CCL terminal value is text, so decode.int never matches a parsed
document. This reads the same lexical form get_int accepts, and is what
belongs in a decoder for an Int field.
import gleam/dynamic/decode
let decoder = {
use port <- decode.field("port", ccl.int_decoder())
decode.success(port)
}
ccl.decode("port = 8080\n", decoder)
// -> Ok(8080)
pub fn keys(
doc: Document,
key: List(String),
) -> Result(List(String), GetError)
Read the keys of the block at a key path, in source order.
An empty path returns the document’s top-level keys.
let assert Ok(doc) = ccl.parse("server =\n host = localhost\n port = 8080\n")
ccl.keys(doc, ["server"])
// -> Ok(["host", "port"])
pub fn line_column(input: String, offset: Int) -> Position
Convert a byte offset into a one-based line and column.
Offsets beyond the end of the input return the position just after the last character. CRLF counts as a single line break.
pub fn new() -> Document
Create an empty CCL document.
Equivalent to parse("") for downstream callers. The only observable
difference: a document from parse("") records that its source had no
trailing newline, so its edits emit none, while a document from new
always ends its output with one.
pub fn parse(input: String) -> Result(Document, ParseError)
Parse CCL text into a document using the default options.
The returned Document preserves the source text, key order, comments, and
indentation for round-tripping.
let assert Ok(doc) = ccl.parse("answer = 42\n")
let assert Ok(42) = ccl.get_int(doc, ["answer"])
pub fn parse_bytes(
input: BitArray,
) -> Result(Document, ParseError)
Parse CCL bytes into a document.
This validates that the input is UTF-8 before parsing.
let assert Ok(doc) = ccl.parse_bytes(<<"answer = 42\n":utf8>>)
ccl.parse_bytes(<<110, 97, 109, 101, 32, 61, 32, 255, 10>>)
// -> Error(ccl.InvalidEncoding)
pub fn parse_bytes_with(
input: BitArray,
options: Options,
) -> Result(Document, ParseError)
Parse CCL bytes into a document with the given options.
pub fn parse_dynamic(
input: String,
) -> Result(dynamic.Dynamic, ParseError)
Parse CCL text into decoder-friendly dynamic data.
See to_dynamic for the shape of the result.
pub fn parse_dynamic_with(
input: String,
options: Options,
) -> Result(dynamic.Dynamic, ParseError)
Parse CCL text into decoder-friendly dynamic data with the given options.
pub fn parse_indented(
input: String,
) -> Result(Document, ParseError)
Parse pre-indented CCL text, detecting the baseline indentation from the first content line rather than assuming column 0.
Use this for a CCL fragment lifted out of a larger document, where every line still has the enclosing block’s indentation.
let assert Ok(doc) = ccl.parse_indented(" host = localhost\n")
let assert Ok("localhost") = ccl.get_string(doc, ["host"])
pub fn parse_indented_with(
input: String,
options: Options,
) -> Result(Document, ParseError)
Parse pre-indented CCL text with the given options.
pub fn parse_value(input: String) -> Result(Value, ParseError)
Parse a standalone CCL value, as it would appear on the right-hand side of
an =.
A single-line input is always a terminal StringValue, even when it
contains an = — that = is content, not a delimiter. A multi-line input
expands into an ObjectValue or ListValue.
ccl.parse_value("localhost")
// -> Ok(ccl.StringValue("localhost"))
ccl.parse_value("\n host = localhost\n")
// -> Ok(ccl.ObjectValue([#("host", ccl.StringValue("localhost"))]))
pub fn parse_value_with(
input: String,
options: Options,
) -> Result(Value, ParseError)
Parse a standalone CCL value with the given options.
pub fn parse_with(
input: String,
options: Options,
) -> Result(Document, ParseError)
Parse CCL text into a document with the given options.
pub fn position_column(position: Position) -> Int
Return the one-based column number for a source position.
pub fn position_line(position: Position) -> Int
Return the one-based line number for a source position.
pub fn print(entries entries: List(Entry)) -> String
Emit a flat entry list as CCL text.
This is CCL’s structure-preserving print, the inverse of the parse pass:
print(entries(doc)) reproduces the document’s source for standard-format
input, without a trailing newline. Use to_string to emit a whole document
with its original trailing newline restored.
pub fn remove(
doc: Document,
key: List(String),
) -> Result(Document, EditError)
Remove the value at a key path.
Removing a key that appears more than once removes every occurrence. Removing the last entry of a nested block leaves the block’s key in place with an empty value.
pub fn set_bool(
doc: Document,
key: List(String),
value: Bool,
) -> Result(Document, EditError)
Set a boolean value at a key path, written as true or false.
pub fn set_float(
doc: Document,
key: List(String),
value: Float,
) -> Result(Document, EditError)
Set a float value at a key path.
pub fn set_int(
doc: Document,
key: List(String),
value: Int,
) -> Result(Document, EditError)
Set an integer value at a key path.
pub fn set_list(
doc: Document,
key: List(String),
values: List(String),
) -> Result(Document, EditError)
Set a list of terminal strings at a key path, written as CCL’s named-list shape.
let assert Ok(doc) = ccl.set_list(ccl.new(), ["ports"], ["80", "443"])
ccl.to_string(doc)
// -> "ports =\n = 80\n = 443\n"
pub fn set_object(
doc: Document,
key: List(String),
pairs: List(#(String, Value)),
) -> Result(Document, EditError)
Set a nested block at a key path from an ordered list of entries.
pub fn set_string(
doc: Document,
key: List(String),
value: String,
) -> Result(Document, EditError)
Set a terminal string value at a key path, creating intermediate blocks as needed.
The value may not contain a newline; use set_value with an ObjectValue
or ListValue for multi-line data.
let assert Ok(doc) = ccl.parse("server =\n host = localhost\n")
let assert Ok(updated) = ccl.set_string(doc, ["server", "host"], "example.com")
ccl.to_string(updated)
// -> "server =\n host = example.com\n"
pub fn set_value(
doc: Document,
key: List(String),
value: Value,
) -> Result(Document, EditError)
Set any Value at a key path, creating intermediate blocks as needed.
This writes nested values with two-space indentation relative to their parent. It replaces an existing key in place, so the key keeps its position and the comments around it.
pub fn to_canonical_string(doc: Document) -> String
Emit a document in CCL’s canonical form: normalised two-space indentation, keys sorted lexicographically, and duplicate keys merged.
This preserves meaning rather than layout, so comments and source order do not survive.
pub fn to_dynamic(doc: Document) -> dynamic.Dynamic
Convert a document to decoder-friendly dynamic data.
The shape is intentionally JSON-like: nested blocks become property maps,
repeated empty-key entries become lists, and terminal values become
strings. A block that holds only a list — CCL’s key =\n = a\n = b —
becomes the list itself rather than a map with an empty-string key.
pub fn to_string(doc: Document) -> String
Emit a document as CCL text.
Unedited parsed documents round-trip to their original source text. An edited document re-emits from its entries and keeps key order, comments, and the indentation of untouched blocks.
pub fn to_value(doc: Document) -> Value
Read a document as a single ObjectValue, in source order.
Equivalent to get(doc, []).
pub fn value_get(
value: Value,
key: List(String),
) -> Result(Value, GetError)
Read a value nested inside a Value.
Mirrors get, but operates on a Value already obtained from get, so
nested data can be read without re-walking from the document root. Errors
report the path relative to the supplied value.
pub fn with_baseline(
options: Options,
baseline: Baseline,
) -> Options
Set the indentation baseline used for top-level entries.
pub fn with_booleans(
options: Options,
booleans: Booleans,
) -> Options
Set which strings get_bool and as_bool accept.
pub fn with_delimiter(
options: Options,
delimiter: Delimiter,
) -> Options
Set how the parser locates the = delimiter.
pub fn with_line_endings(
options: Options,
line_endings: LineEndings,
) -> Options
Set how the parser treats CRLF line endings.
pub fn with_list_coercion(
options: Options,
coercion: ListCoercion,
) -> Options
Set whether a terminal value reads as a one-element list.