joetjen.net
EN DE
Configuration language

Grammar

CASC is defined by a PEG grammar written in Aether, the grammar language of Ichor. The Elixir and Praxis parsers are generated from it rather than written by hand; the Node.js and PHP parsers port it rule for rule.

Format 1.0

Pragmas and tokens

This is the complete grammar from priv/grammar/casc.aether, as released with Cooper 0.7.0 and unchanged since 0.6.0, with only its comments removed. Upper-case names are tokens, matched longest-first with ties going to the earlier declaration; lower-case names are rules, matched over the tokens by ordered choice. @skip TRIVIA lets whitespace and comments sit between any two elements of a rule.

casc.aether
@grammar "casc"
@root file
@skip TRIVIA

COMMENT := "#" !("@" | ALPHA | "*") (!"\n" .)*
TRIVIA  := (SPACE | COMMENT)*

NIL_KW   := "nil"
TRUE_KW  := "true"
FALSE_KW := "false"
INF_KW   := ("+" | "-")? "inf" !(ALNUM | "_" | "+" | "-" | "?" | "!")

IDENT := ("_" | ALPHA) (ALNUM | "_" | "+" | "-")* ("?" | "!")?

DATE     := DIGIT{4} "-" DIGIT{2} "-" DIGIT{2}
TIME     := DIGIT{2} ":" DIGIT{2} ":" DIGIT{2} ("." DIGIT+)?
DATETIME := DATE "T" TIME (("Z") | (("+" | "-") DIGIT{2} ":" DIGIT{2}))?

HEX_GROUP := HEX{1,4}
IPV4 := DIGIT{1,3} "." DIGIT{1,3} "." DIGIT{1,3} "." DIGIT{1,3} ("/" DIGIT{1,3})?
IPV6 := (HEX_GROUP (":" HEX_GROUP){7} | (HEX_GROUP (":" HEX_GROUP)*)? "::" (HEX_GROUP (":" HEX_GROUP)*)?)
        ("/" DIGIT{1,3})?

DURATION_UNIT := "ns" | "us" | "µs" | "ms" | "d" | "h" | "m" | "s"
DURATION := DIGIT (DIGIT | "_")* ("." DIGIT (DIGIT | "_")*)? DURATION_UNIT
            (DIGIT (DIGIT | "_")* DURATION_UNIT)*

BYTE_UNIT := "PiB"i | "TiB"i | "GiB"i | "MiB"i | "KiB"i
           | "PB"i | "TB"i | "GB"i | "MB"i | "kB"i | "B"i
BYTES := DIGIT+ ("." DIGIT+)? BYTE_UNIT

INTEGER := ("+" | "-")? (
             "0x" HEX (HEX | "_")*
           | "0o" [0-7] ([0-7] | "_")*
           | "0b" [01] ([01] | "_")*
           | DIGIT (DIGIT | "_")*
           )
FLOAT := ("+" | "-")? DIGIT (DIGIT | "_")* (
           "." DIGIT (DIGIT | "_")* (("e" | "E") ("+" | "-")? DIGIT+)?
         | ("e" | "E") ("+" | "-")? DIGIT+
         )

TRIPLE_STRING := "\"\"\"" (!"\"\"\"" .)* "\"\"\""
DQ_STRING     := "\"" ("\\" . | !"\"" .)* "\""
SQ_STRING     := "'" (!"'" .)* "'"

RESOLVER_REF_RAW := @native("Cooper.Native.ResolverRef", "scan")

DIGIT, ALPHA, ALNUM, SPACE and HEX are predefined in every Aether grammar. RESOLVER_REF_RAW is the one token matched by Elixir code instead of a pattern: a !{…} resolver reference may nest braces, which no fixed token can count.

Statements

file := TRIVIA? version_header statement* TRIVIA?

version_header := "#" "@" kw:IDENT "="? version_number

version_head   := FLOAT | INTEGER
version_number := version_head ("." n:INTEGER)*

statement          := disabled_statement | conditional_statement | real_statement
disabled_statement := "#" real_statement

conditional_statement := env_guard real_statement
env_guard              := "${?" name:IDENT "}"

real_statement := var_decl | for_statement | import_statement | followed_delete
                | sigil_kv_statement | delete_statement

followed_delete := delete_statement &statement

var_decl := "@" private:"*"? name:IDENT "="? value

import_statement := import_kw:IDENT path:import_path
import_path := DQ_STRING | SQ_STRING

for_statement := for_kw:IDENT binding ("," binding)*
                  ((from_kw:IDENT template:key_path as_kw:IDENT dest:key_path)
                  | (as_kw:IDENT dest:key_path))
                  block

binding := "@" name:IDENT (in_kw:IDENT iterable:iterable_value)?

iterable_value := NIL_KW | TRUE_KW | FALSE_KW | INF_KW
                 | DATETIME | DATE | TIME | IPV6 | IPV4 | DURATION | BYTES
                 | FLOAT | INTEGER
                 | TRIPLE_STRING | DQ_STRING | SQ_STRING
                 | list | tuple
                 | at_ref | env_ref | config_ref | resolver_ref | tagged_ref

sigil_kv_statement := sigil:("~" | "+" | "-")? key_path "="? rhs
delete_statement    := "-" key_path

rhs := block | value

block := "{" (statement ","?)* "}"

key_path    := key_segment ("." key_segment)*
key_segment := secret:"*"? seg

seg := IDENT | DQ_STRING | SQ_STRING

Values and references

value := NIL_KW | TRUE_KW | FALSE_KW | INF_KW
       | DATETIME | DATE | TIME | IPV6 | IPV4 | DURATION | BYTES
       | FLOAT | INTEGER
       | TRIPLE_STRING | DQ_STRING | SQ_STRING
       | list | tuple
       | at_ref | env_ref | config_ref | resolver_ref | tagged_ref
       | atom_value

at_ref     := "@{" name:ref_name idx_suffix? ref_suffix? filters? "}"
env_ref    := "${" name:ref_name env_bracket? ref_suffix? filters? "}"
config_ref := "%{" path:key_path idx_suffix? ref_suffix? filters? "}"

ref_name   := IDENT | DQ_STRING

resolver_ref := raw:RESOLVER_REF_RAW
tagged_ref   := "!" name:IDENT "(" arg:value ")"

idx_suffix  := "[" INTEGER "]"
env_bracket := "[" INTEGER? "]"

ref_suffix := (":" "?" msg:DQ_STRING) | (":" "+" alt:value)
            | (":" signed_alt:signed_number) | (":" default:value)

signed_number := INTEGER | FLOAT | INF_KW

filters := ("|" filter)+
filter     := name:IDENT (":" arg:filter_arg)?
filter_arg := DQ_STRING | SQ_STRING

list  := "[" (element ","?)* "]"
tuple := "(" (element ","?)* ")"

element := block | value

atom_value := colon_atom | IDENT
colon_atom := ":" atom_word
atom_word  := IDENT | NIL_KW | TRUE_KW | FALSE_KW | INF_KW

Interpolation

A double-quoted string is parsed a second time by its own grammar, priv/grammar/casc_interp.aether, which splits it into text chunks and embedded references. Each reference ends at its first closing brace or parenthesis.

casc_interp.aether
@grammar "casc_interp"
@root text
@noskip

AT_REF_RAW     := "@{" (!"}" .)* "}"
ENV_REF_RAW    := "${" (!"}" .)* "}"
CONFIG_REF_RAW := "%{" (!"}" .)* "}"

RESOLVER_REF_RAW := @native("Cooper.Native.ResolverRef", "scan")

TAG_NAME       := ALPHA (ALNUM | "_" | "+" | "-")* ("?" | "!")?
TAGGED_REF_RAW := "!" TAG_NAME "(" (!")" .)* ")"

BANG_TAG   := "!" TAG_NAME "("
TEXT_CHUNK := (!("@{" | "${" | "%{" | "!{" | BANG_TAG) .)+

text    := segment*
segment := AT_REF_RAW | ENV_REF_RAW | CONFIG_REF_RAW | RESOLVER_REF_RAW | TAGGED_REF_RAW | TEXT_CHUNK

Error categories

Every load error belongs to exactly one category, named by the stage of loading that refused the document. Each implementation reports errors in its own way, but must expose the category. Categories are compared exactly; messages are not part of the specification.

CategoryRaised when
lexerthe text cannot be split into tokens: an unterminated string, a character no token starts with
parserthe tokens do not form a document: a missing or malformed #@version header, a statement the grammar has no rule for
actiona literal is well formed but means nothing: an impossible date, an address out of range, a malformed duration or byte size
importan import cannot be read: no file, a cycle, a scheme nobody registered
mergetwo values cannot be combined: a list operation on a value that is not a list
loopa for cannot run: an iterable that is not a list, bound lists of different lengths, an index-only loop
resolvea reference cannot be answered: an undefined name with no default, a cycle, a resolver or tag that is not registered or fails, a value with no string form interpolated
dotenva .env file read for ${…} cannot be parsed

Each implementation still says where a document went wrong. Since 0.7.0, Cooper reports a parse error at the furthest token the parser reached, as unexpected X -- expected one of: …, and a literal refused at the action stage with its line, its column and the source line under it:

#@version = 1.0
name = "api"
allow = [10.0.0.1, 300.0.0.1]
3:20: invalid IP address: "300.0.0.1"
3 | allow = [10.0.0.1, 300.0.0.1]
  |                    ^

Where the implementations differ

The specification is ahead of the implementations in two places. Until they meet, these are the portable choices:

SpecificationCurrentlyWrite
A file’s own statements win over its importsStrictly by positionImports first
A \ at the end of a line continues a bare valueNot implemented: a lexer errorA triple-quoted string

Four earlier differences are gone since Cooper 0.5.0: comments may precede the header, key "value" without = is an assignment, durations take _ separators, and an interpolated key works outside a loop too. A fifth went with node-cooper 0.2.2: a mapped module now reads in a string as what it maps to in Node.js too, as it does in Elixir and PHP.

List merge by key (~items[id] = […]) is reserved in the specification and deliberately not part of the language yet.