Copperlace Configuration

Copperlace reads configuration into a RuleSet. The current parser accepts HOCON syntax, but the renderer behavior described here is independent of that implementation detail.

Root Shape

The config root must be an object. Any non-object root fails compilation with InvalidConfigRoot.

origin = "{% hero:name %}{story}"
name = ["Mia"]
story = "{hero} walked home."

Top-Level Keys

Top-level keys become named rules unless the key is context and its value is an object. Path keys and nested objects can define dotted rule names.

origin = "{story}"
story = "A {mood} path"
mood = [bright, quiet]
name.first = ["Mia"]

Each top-level rule can be rendered directly by passing its key to RuleSet::render_rule. Dotted leaves can also be rendered directly, such as RuleSet::render_rule("name.first").

The selected top-level rule’s value shape also determines whether it is a text or structured render target:

  • string-valued top-level rules render as text;

  • list-valued top-level rules render as random text choices;

  • object-valued top-level rules can render structurally.

See Structured output for examples and CLI mode inference.

Special context Object

When top-level context is an object, its entries become lazy default values used by template references. They are not inserted into the normal rule table, but they can still be rendered by name through render_rule.

context = {
  hero = "{name}"
}

If top-level context is not an object, it is treated as a normal rule named context.

Callers may also pass initial render context values to a render call through Rust APIs, wrapper render overloads, JS/WASM context methods, or CLI --set key=value. Initial context values are strings, resolve before config-defined context defaults and named rules, and do not persist after the render finishes.

String Values

Strings are parsed as templates. Each {…​} section becomes a render-time expression that appends text to the output. Each {% …​ %} section becomes a render-time statement that may update render state but appends no text. All other text remains literal.

origin = "Hello {name}"

Literal expression braces can be escaped in template text with \{ and \}. Literal statement delimiters can be escaped with \{% and %\}. In normal quoted strings, escape the backslash itself as \\{, \\}, \\{%, or %\\}. In triple-quoted strings, write \{, \}, \{%, or %\} directly.

inline = "\\{name\\}"
json = """\{
  "name": {name | quote}
\}"""
statement = """\{% hero:name %\}"""

Supported expressions:

  • {rule} resolves a bound value, context default, or named rule.

  • {rule | processor} renders rule and passes the result through one or more processors from left to right.

  • {rule!} is a strict unique choice call. It resolves existing bound values and context defaults normally. If no value exists, rule must be an array-backed choice rule, and Copperlace selects an unused array entry for that rule within the current render.

Strict unique calls can use processors and binding statements:

hero = [Mia, Lina]
origin = "{hero! | uppercase} faces {% rival:hero! %}{rival}"

Repeated {rule!} calls share one used-entry set per rule name for the current render operation. The set resets for the next render. Exhaustion fails with ExhaustedUniqueChoice(rule), and using ! with a non-choice rule fails with UnsupportedUniqueChoice(rule). Uniqueness is by array entry index, not by the rendered text. Weighted unique choices sample only among unused entries using their original weights; if the remaining unused entries have no positive total weight, rendering fails.

The ! marker does not propagate through lazy context defaults. If {hero!} resolves context.hero = "{name}", the default is rendered and cached normally. Use context.hero = "{name!}" when the nested name draw should be unique.

Limited Recursion

Recursive rule references are disabled by default. If rule expansion re-enters a rule already on the current call stack, rendering fails with CircularRuleReference.

Callers may opt in to limited recursion with the render-time max_recursion_depth option, or CLI --max-recursion-depth <n>. A value of 0 keeps recursion disabled. Values greater than zero allow that many recursive re-entries for each rule name. When another recursive call would exceed the limit, that call returns an empty string.

origin = "x{origin}"

Rendering origin with max_recursion_depth = 1 produces xx: the first recursive re-entry is rendered, and the next recursive call is cut off.

The limit applies to named rules and lazy context defaults. Initial context values and values bound earlier in the render still resolve before rules and do not consume recursion depth.

Supported statements:

  • {% alias:rule %} binds the rendered rule value to alias if alias is not already bound for this render.

  • {% alias:=rule %} always renders rule, stores the result under alias, and replaces any existing value.

Binding statements can also use processors. {% alias:rule | uppercase %} stores the processed result under alias; {% alias:=rule | uppercase %} overwrites alias with the processed result.

Overwrite bindings are parsed before bind-if-missing bindings, so {% alias:=rule %} is distinct from {% alias:rule %}.

Supported builtin processors are:

  • uppercase

  • lowercase

  • trim

  • capitalize

  • titlecase

  • article

  • past_tense

  • pluralize

  • singularize

  • possessive

  • present_participle

  • ordinal

  • sentence

  • quote

  • slug

The article processor prefixes the rendered value with a or an using English heuristics. It preserves the rendered value as-is, so use trim first when surrounding whitespace should be ignored.

item = ["apple", "user", "hour"]
origin = "You found {item | article}."

The past_tense processor converts one verb token to past tense using regular spelling rules and common irregular verbs. It preserves surrounding whitespace but returns an error for blank values or multi-word phrases.

action = ["walk", "run", "try"]
origin = "Mia {action | past_tense}."

The pluralize processor converts one noun token to plural form using regular spelling rules and common irregular nouns. It preserves surrounding whitespace but returns an error for blank values or multi-word phrases.

creature = ["cat", "person", "city"]
origin = "The town has many {creature | pluralize}."

The singularize processor converts one plural noun token to singular form using common reverse spelling rules and irregular nouns. It preserves surrounding whitespace but returns an error for blank values or multi-word phrases.

creatures = ["cats", "people", "cities"]
origin = "One {creatures | singularize} waits."

The possessive processor adds an English possessive suffix to one token. It preserves surrounding whitespace but returns an error for blank values or multi-word phrases.

name = ["Mia", "James"]
origin = "{name | possessive} lantern glows."

The present_participle processor converts one verb token to its -ing form. It preserves surrounding whitespace but returns an error for blank values or multi-word phrases.

action = ["walk", "run", "lie"]
origin = "Mia is {action | present_participle}."

The ordinal processor adds an English ordinal suffix to one integer token. It preserves surrounding whitespace but returns an error for blank, multi-word, or non-integer values.

rank = [1, 2, 3, 11]
origin = "Mia finished {rank | ordinal}."

The sentence processor capitalizes the first alphabetic character in the rendered string and leaves the rest unchanged.

line = ["hello MIA"]
origin = "{line | sentence}"

The quote processor wraps the rendered string in ASCII double quotes and escapes internal double quotes and backslashes.

line = ["Mia said \"hi\""]
origin = "{line | quote}"

The slug processor lowercases rendered text, trims it, converts runs of non-alphanumeric characters to hyphens, removes apostrophes, and strips leading or trailing hyphens.

title = ["Mia's Story"]
origin = "{title | slug}"

Iteration

{% for item in items %}…​{% endfor %} iterates an array in source order and concatenates the text rendered by the loop body. An empty array renders no text. Top-level arrays, dotted config paths, arrays from the special context object, and array fields of an enclosing loop element can be sources.

groups = [
  { name = A, members = [Mia, Lina] },
  { name = B, members = [Noah] }
]

origin = """{% for group in groups %}
{group.name}: {% for member in group.members %}{member} {% endfor %}
{% endfor %}"""

Scalar elements render through the loop variable, such as {member}. Object fields use dotted paths such as {group.name}. Array and object values are not renderable directly as text, but their fields and nested arrays can be used. Templates in an element or field are rendered on every reference, so repeated references may make different random choices.

Each iteration exposes an immutable loop object:

  • loop.index is the one-based index;

  • loop.index0 is the zero-based index;

  • loop.length is the source length;

  • loop.first and loop.last are booleans.

Nested loops temporarily shadow loop and restore the outer metadata when the inner loop finishes. The name loop cannot be used as the element variable. Loop elements and metadata cannot be overwritten.

Bindings created by {% alias:rule %} or {% alias:=rule %} inside the body are local to that iteration. They are visible to nested loops and the remainder of the current iteration, then discarded. Values from outer scopes remain visible and are restored afterward.

Name lookup is lexical. A visible string binding shadows a config array with the same name, so attempting to iterate it returns UnsupportedIterationSource. Weighted array entries are exposed exactly as authored: { value = red, weight = 2 } is an object addressed with {item.value} and {item.weight}. Iteration never applies weights.

Loops inside quoted text templates produce text only. In an object-valued structured rule, an unquoted loop block can instead occupy one array-entry position. Its body must contain one HOCON value, which is rendered once per source element and appended to that array. The emitted value may be a scalar, object, or nested array. Nested structured loops are allowed.

items = [{ name = Mia }, { name = Lina }]
shared = 3

origin {
  entries = [
    {% for item in items %}
    { name = "{item.name}", index = "{loop.index}", value = ${shared} }
    {% endfor %}
  ]
}

The block is recognized only outside quoted strings and comments. Text loops inside a string continue to concatenate their rendered body as before.

A HOCON substitution such as ${shared} in a structured loop body resolves against the fully merged configuration when it is loaded, retaining its value type. A Copperlace binding such as {item.name} renders for each iteration. The example emits value = 3 for both entries while name changes. The two forms can be used in nested loop bodies and included .conf files. The rest of the configuration follows normal HOCON substitution rules, including required and optional substitutions.

A structured array produced by a loop is output data; loop sources continue to be authored config arrays, context arrays, dotted paths, and arrays on outer loop elements. File-loaded .conf fragments can also contain structured loops, including fragments imported through include classpath(…​), include file(…​), or bare include "…​". Nested imports use the same behavior.

Array Values

Arrays are random choices. The selected element is rendered using the same value rules as a top-level config value.

mood = [vexed, wistful, astute]

Arrays may contain weighted entries. A weighted entry is an object with only value and weight fields. The value field is rendered using the same rules as any other array element, and weight must be a finite non-negative number. If any array entry is weighted, plain entries in the same array receive weight 1.0.

mood = [
  { value = vexed, weight = 6 },
  { value = wistful, weight = 2.5 },
  astute
]

Individual zero weights are accepted, but a weighted choice must have at least one entry with a positive weight. Invalid weighted choices fail during RuleSet construction.

An empty array is accepted during RuleSet construction, but rendering it fails with EmptyChoice.

Strict unique calls such as {mood!} select without replacement from the top-level array-backed mood rule for one render operation. Empty arrays still fail with EmptyChoice.

Inside an object-valued structured rule, arrays preserve their shape instead of acting as random choices. Top-level list structured rendering is not supported in v1; top-level list rules remain text choices.

Arrays inside object-valued structured rules are preserved for structured rendering. When a nested array looks like a malformed weighted text choice, it is still accepted as structured data; rendering that array as a dotted text rule fails with UnsupportedValue("array").

Scalar Values

Scalar values that are not strings, arrays, or objects are converted to strings using the parsed value’s to_string behavior.

count = 3
origin = "Count: {count}"

Object Values

Objects can organize rules into dotted names. Path keys and nested object syntax are equivalent, so name.first = ["Mia"] and name { first = ["Mia"] } both define a rule named name.first.

name {
  first = ["Mia"]
  family = ["Darcy"]
}

origin = "{name.first} {name.family}"

Object parents are not directly renderable. Rendering name in the example above fails with UnsupportedValue("object").

Object-valued top-level rules are structured render targets. Structured rendering preserves nested objects and arrays, while string leaves still use normal templates, rule calls, processors, bindings, context defaults, and initial context values.

Objects are also supported as the special top-level context value and as weighted choice entries inside arrays. Nested context values use the same dotted name behavior.

origin = { value = "nested" }

Example Configuration

name = ["Arjun", "Yuuma", "Darcy", "Mia", "Chiaki", "Izzi", "Azra", "Lina"]

animal = [unicorn, raven, sparrow, scorpion, coyote, eagle, owl,
    lizard, zebra, duck, kitten]

mood = [vexed, indignant, impassioned, wistful, astute, courteous]

story = [
  "{hero} traveled with her pet {heroPet}. {hero} was never {mood}, for the {heroPet} was always too {mood}."
]

origin = "{% hero:name %}{% heroPet:animal %}{story}"

context = {
  hero = "{name}"
  heroPet = "{animal}"
}