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

  • StripToplevelIndent

    The top-level baseline is always column 0, matching the OCaml reference. The default.

  • PreserveToplevelIndent

    The 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

  • BooleanStrict

    Only true and false, case-insensitively. The default.

  • BooleanLenient

    Also accept yes/no, on/off, and 1/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

  • FirstEquals

    Always split on the first = in the line.

  • PreferSpaced

    Prefer 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

  • EmptyKeyPath

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

  • InvalidCommentText

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

  • InvalidValue

    The supplied value cannot be represented in the requested edit context. A set_string value containing a newline is the common case; use set_value with an ObjectValue or ListValue for 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

  • NormalizeCrlf

    Rewrite every \r\n to \n before parsing. The cross-platform default.

  • PreserveCrlf

    Keep \r characters 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

  • CoercionDisabled

    Reading a list from a terminal value is a WrongType error. The default.

  • CoercionEnabled

    A terminal value reads as a one-element list.

The order in which repeated empty-key entries are collected.

pub type ListOrder {
  InsertionOrder
  LexicographicOrder
}

Constructors

  • InsertionOrder

    Elements keep their source order. The default.

  • LexicographicOrder

    Elements 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

  • InvalidEncoding

    The 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

  • ExpectedKey

    The parser expected a key before the = delimiter.

  • ExpectedValue

    The parser expected a value after the = delimiter.

  • InvalidCcl

    CCL 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

  • TabsAsWhitespace

    Spaces and tabs both count as indentation whitespace. The default.

  • TabsAsContent

    Only 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

  • StringValue(String)

    A terminal value — the fixed point, with no further = to expand.

  • ObjectValue(List(#(String, Value)))

    A nested block, in source order.

  • ListValue(List(Value))

    A list accumulated from repeated empty-key entries.

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 options(doc: Document) -> Options

Return the options a document was parsed with.

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_model(doc: Document) -> Model

Read a document as CCL’s canonical recursive Model.

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.

pub fn with_list_order(
  options: Options,
  order: ListOrder,
) -> Options

Set the order in which repeated empty-key entries are collected.

pub fn with_tabs(options: Options, tabs: Tabs) -> Options

Set how the parser treats tab characters.

Search Document