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.
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.
@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_STRINGValues 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_KWInterpolation
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.
@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_CHUNKError 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.
| Category | Raised when |
|---|---|
lexer | the text cannot be split into tokens: an unterminated string, a character no token starts with |
parser | the tokens do not form a document: a missing or malformed #@version header, a statement the grammar has no rule for |
action | a literal is well formed but means nothing: an impossible date, an address out of range, a malformed duration or byte size |
import | an import cannot be read: no file, a cycle, a scheme nobody registered |
merge | two values cannot be combined: a list operation on a value that is not a list |
loop | a for cannot run: an iterable that is not a list, bound lists of different lengths, an index-only loop |
resolve | a 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 |
dotenv | a .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:
| Specification | Currently | Write |
|---|---|---|
| A file’s own statements win over its imports | Strictly by position | Imports first |
A \ at the end of a line continues a bare value | Not implemented: a lexer error | A 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.