Skip to content

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=N and cla fmt --indent=N write 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.50 and 1e3 survive.
  • 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.