Syntax
The Clarive HCL grammar: what lexes, what parses, what is refused, and what
cla fmt does to it.
Blocks¶
generic_server "web01" {
hostname = "web01.example.invalid"
}
resource "GitRepository" "foo-core" {
url = "https://git.example.invalid/foo/core.git"
}
moved {
to = generic_server.web01
names = ["old-web"]
}
A block is TYPE LABEL* { BODY }. Zero, one or two labels; labels are quoted
strings. A bare word where a label belongs is
HCL011 Block label bar must be a quoted string. A third
label is HCL010 — an error, but a non-throwing one:
parsing continues and every label is kept in the tree. The file is still
refused, because any error refuses the run.
A body holds attributes, nested blocks and comments, in source order. Order is part of the contract: the formatter reproduces it.
pipeline "foo" {
step "PRE" {
sh "restart" {
run = "systemctl restart foo"
}
}
}
Nesting is unlimited. An empty body prints as {}:
wait "join" {}
user "bar" {}
A single attribute on the block's own line parses:
resource "AcmeThing" "bar" { owner = generic_server.foo }
Two do not — the second is HCL016 Expected end of line
after the value of owner. cla fmt expands the one-line form to multi-line
anyway, so it survives only until the next format.
There is no foo = { ... } block form. = { introduces an object literal,
which is a different node with different semantics.
Attributes¶
name = "Foo Bar"
count = 3
ratio = 1.5
big = 1e3
flag = true
gone = null
NAME = EXPRESSION, one per line. The name is an identifier:
[A-Za-z_][A-Za-z0-9_-]* — dashes are legal after the first character,
Unicode is not.
An attribute with no = is HCL015; anything after the
value on the same line is HCL016.
Strings¶
plain = "foo"
escaped = "he said \"bar\", then a tab:\tand a newline:\n"
unicode = "café"
literal = "cd ${job_dir}/baz && echo qux"
Double quotes only. A quoted string cannot span lines — HCL002 Unterminated string, a quoted string cannot span lines. Single quotes and backticks are not string delimiters; both produce HCL001 with a targeted hint.
Recognised escapes: \n, \t, \r, \", \\, \uXXXX (four hex digits),
\UXXXXXXXX (eight). Anything else is HCL005 Invalid
escape sequence and the backslash is kept verbatim so nothing is silently
eaten. There is no \a, \0 or \xNN.
cla fmt re-escapes only \, ", newline, tab and carriage return. A
\uXXXX or \UXXXXXXXX escape is therefore decoded once and written back as
the literal character: "\U0001F600" formats to "😀". Both spellings mean
the same string; only the first survives a format.
${...} is literal¶
Clarive HCL never interpolates. ${job_dir} inside a string is the ten
characters ${job_dir}, and it reaches the rule runtime byte for byte, where
the stash expands it.
run = "cd ${job_dir}/baz && echo $${literal} %{ $h }"
That value is exactly cd ${job_dir}/baz && echo $${literal} %{ $h }. In
particular $${ is not an escape for ${ — it is two dollar signs, and it
round-trips through cla fmt unchanged. Any habit carried over from Terraform
escaping will corrupt a job's shell line.
Outside a string, a bare $ is HCL001 only meaningful
inside a quoted string or heredoc, and %{ lexes as an operator followed by a
brace, giving HCL020 unary operator %.
Heredocs¶
perl "run perl" {
code = <<-EOT
my $x = 1;
return $x;
EOT
}
script "raw" {
body = <<EOT
line one
line two indented
EOT
}
Two forms, and only two: <<DELIM and <<-DELIM. The delimiter is a bare
identifier — <<"EOT" is not supported and fails as an operator. A newline
must follow the delimiter; spaces and tabs between are allowed.
| Form | Closing marker | Body |
|---|---|---|
<<EOT |
must be at column zero | verbatim |
<<-EOT |
may be indented | indentation of the least-indented non-blank line is stripped |
Blank lines never drive the <<- indent calculation. Bodies are byte-for-byte
verbatim otherwise — a heredoc is the only place in a Clarive HCL file where
trailing whitespace survives, because there it is content.
cla fmt re-indents a <<- body to the block's level and leaves a plain
<<EOT body and its closing delimiter at column zero, even deep inside
nested blocks. Indenting it would produce a file that no longer parses.
An unterminated heredoc is HCL003.
Lists¶
groups = [group.auditors, group.developers]
exclude_path = [
"foo/build/**",
"bar/target/**",
"baz/node_modules/**",
"qux/vendor/**",
]
Elements are separated by commas or by newlines; a trailing comma is tolerated.
Nesting is fine. cla fmt keeps a list on one line while it fits within 80
columns measured on the finished line, including the name = prefix, and
otherwise puts one element per line with a trailing comma on every element.
A heredoc element never gets a comma, because a heredoc ends on its delimiter line and the lexer will not accept a delimiter with anything appended. The newline is the separator.
An unclosed list is HCL018.
Object literals¶
cond = {
a = "bl"
op = "eq"
b = "prod"
ignore_case = true
}
Keys may be bare identifiers or quoted strings; the separator may be = or
:; entries are separated by commas or newlines; a trailing comma is
tolerated. Both the key quoting and the separator choice are preserved verbatim
by the formatter, so a mixed object stays mixed:
foo = {
"a" : 1
b = 2
}
Object literals are always expanded by cla fmt, one entry per line,
regardless of length. Only a genuinely empty object collapses to {}. Unlike
lists, objects are not width-checked.
A malformed entry is HCL019.
References¶
proxy = generic_server.bastion
groups = [group.auditors, group.developers]
host = generic_server.web01
id_repo = resource.ArtifactLocal.public
A reference is a bare, unquoted, dotted traversal. Any depth. The parser does not know what it points at; resolution happens later, against the installation being imported into. See Addresses and references.
A bare identifier with no dot is HCL017:
run = bar
Bare identifier 'bar' used as a value -- did you mean the string "bar" or a reference like resource.<class>.<name>?
An attribute that expects references and is given a literal is
HCL206 Attribute groups expects references, not a
literal:
user "alice" {
groups = ["developers"]
}
Functions¶
password = b64("aHVudGVyMg==")
token = enc("U2FsdGVkX1+...")
b64 and enc are the entire function library. There is no file(),
jsonencode(), format(), join() or lookup().
| Function | Argument | On import |
|---|---|---|
b64("...") |
base64 of the UTF-8 bytes | decoded; no key needed |
enc("...") |
Clarive cipher text | decrypted with --secret-key; without one, HCL209 |
Both take exactly one argument. The parser accepts any call syntactically, so
an unknown name is caught later with a schema-aware message:
HCL207 Unknown function foo() for object attributes,
HCL306 inside a rule. Wrong arity is
HCL208 / HCL307. A wrong key or a value
that was never encrypted is HCL210 /
HCL309.
b64 is encoding, not encryption. Anyone holding the file holds the value.
Absent versus null on a sensitive attribute¶
clax_agent "local" {
basic_auth_username = "foo"
}
basic_auth_password is not mentioned, so the stored one is kept. The same is
true of an explicit null:
clax_agent "local" {
basic_auth_password = null
}
Both decode to the internal KEEP_EXISTING marker, which the apply step drops
from the write. There is no spelling that blanks a stored secret through HCL.
Non-sensitive attributes behave differently: an absent one takes its class
default, which for many attributes means it is written.
Comments¶
# a leading comment
generic_server "web01" { # on the header line
# a standalone comment inside the body
hostname = "web01.example.invalid" # trailing
/* a block comment
over two lines */
os = "win"
} # after the closing brace
Three forms: #, //, /* ... */. Block comments do not nest.
cla fmt preserves the flavour — # is never rewritten to // — and the
attachment point. Four attachment slots exist and each round-trips: leading,
standalone, block header (after {), and trailing (on the node's last line).
A blank line before a comment run detaches it from the node below.
One normalisation: an inline block comment found mid-construct is hoisted onto
its own line above the node. foo /* why */ = 1 formats to:
/* why */
foo = 1
Comments inside lists, objects and function calls are kept but float to the top of the expanded form; their exact interleaving with elements is not preserved.
An unterminated block comment is HCL004.
Divergences from stock HCL¶
Everything in this table is a deliberate refusal. Clarive HCL v1 does not evaluate expressions at all: a file is data, and the only transformation between the text and the database is a name lookup.
| Construct | Example | Result |
|---|---|---|
| String interpolation | "${var.foo}" |
literal text, no error |
$${ escape |
"$${x}" |
two dollars, literal |
| Template directives | "%{ if a }b%{ endif }" |
literal inside a string; % is HCL020 outside one |
| Conditional | a = b ? c : d |
HCL020 conditional expression |
| Binary operator | a = 1 + 2 |
HCL020 operator + |
| Unary operator | a = !b |
HCL020 unary operator ! |
| Parentheses | a = (1) |
HCL020 parenthesised expression |
| Index access | a = foo.bar[0] |
HCL020 index access — reference Clarive objects by address instead |
| Legacy index | a = foo.0 |
HCL020 index access |
| Splat | a = foo.bar.* |
HCL020 splat expression — list every reference explicitly |
for expression |
a = [for x in y : x] |
HCL020 for expression |
dynamic block |
dynamic "step" { ... } |
HCL020 dynamic block — Clarive HCL v1 does not generate blocks (non-throwing; the block is kept in the tree, and the file is still refused) |
| Bare identifier as value | a = b |
HCL017 |
var. / local. / count. / each. |
a = var.foo |
parses as an ordinary two-part reference and then fails to resolve |
| Single-quoted string | a = 'foo' |
HCL001 HCL strings use double quotes |
| Backtick string | a = `foo` |
HCL001 HCL has no backtick strings, use a heredoc |
| Statement terminator | a = 1; |
HCL001 HCL statements end at the end of the line |
| Hex / octal / underscored numbers | a = 0x10, a = 1_000 |
not lexed as numbers |
<<"EOT" heredoc |
a = <<"EOT" |
HCL020 unary operator < |
variable "foo" {} exists, but it is an ordinary Clarive object block — the
variable CI — not a language-level variable.
A leading - is a sign only in prefix position. a = -3 is the number -3;
a = 1-2 lexes as three tokens and the parser rejects the operator.
Canonical form¶
cla fmt is the only formatter. Its output is what cla export writes and
what the UI editors save.
- Two spaces per nesting level.
cla export --indent=Nandcla fmt --indent=Nwrite another width; indentation carries no meaning, so a file imports the same at any width. - One space either side of
=, aligned within each run of adjacent attributes. A blank line, a comment, a nested block, or an attribute with a leading comment starts a new run — which is what stops one long name from shoving every other line sideways. - One blank line between top level blocks, inserted even when the source had none. Runs of several blank lines collapse to one. Nested blocks get no blank line inserted.
- Lists inline while they fit in 80 columns, otherwise one element per line with trailing commas. Object literals always expanded. Function calls stay on one line unless an argument is multi-line.
- Numbers print their source spelling —
1.50and1e3survive. - Block labels are always re-quoted.
- No trailing whitespace; exactly one newline at end of file. LF only; CR is normalised away by the lexer.
Formatting is a fixed point: formatting twice changes nothing. A file that
fails to parse is never rewritten — cla fmt prints the diagnostic with its
file:line:column and moves to the next file.
cla fmt does not rewrite a block keyword. resource "generic_server" "x"
stays as written; only cla export emits the short spelling.
Error recovery¶
The parser resyncs to the next newline at the current brace depth, stopping
short of a } belonging to an enclosing block. An error inside one block is
contained to that block and later blocks still parse, so a single run reports
every independent problem rather than one at a time.
Diagnostics are capped at 200 items per run, so a pathological file cannot spin out megabytes of errors.
An error makes the whole AST untrustworthy. Every caller checks for errors before touching it; nothing downstream of a parse error is written.
Diagnostic rendering¶
foo.hcl:2:9: error[HCL017]: Bare identifier 'bar' used as a value -- did you mean the string "bar" or a reference like resource.<class>.<name>?
2 | run = bar
| ^
An item with no position renders as foo.hcl: error[HCL000]: .... Tabs in the
context line become single spaces so the caret lands under the right column.
The full code list is on the Diagnostics page.