Heroes / Examples / template

examples/template/

Fill placeholders in a text template with values from a map.

The map associates each placeholder name with its replacement text. A missing name returns an error unless the caller explicitly provides a default. Doubling an opening or closing brace prints one literal brace. A test caught the first version handling only opening braces.

Run it

$ heroes run examples/template/main.hero
Hello, Ada.
Ada the engineer
no holes here
a {literal} brace
{name}
name,role,name
true
true
true
true
Hello, friend.

exit 0 · 11 lines

The program

# Text with holes in it, filled from a map — and the three ways filling one
# can go wrong.
#
# Corpus and regression material (CLAUDE.md §9). It closes no measured form
# gap; what it adds is the shape a person reaches for when they want a
# message, and the reason it is worth a program is that the naive version
# silently produces nonsense. A substitution that cannot find its key has
# three defensible answers — leave the hole, drop it, or refuse — and a
# library that picks one without saying so is the source of every
# "Hello, {name}" anybody has ever received in an email.
#
# THIS ONE REFUSES, and says why in the code: `spec:141`'s `T?` makes refusing
# cost one character at the call site, so the cheapest thing is also the
# loudest. `render_or` is there for the caller who genuinely wants a default,
# and it is a different function rather than a flag, so the choice is visible
# at the call site instead of buried in an argument.
#
# THE ESCAPE IS DOUBLING, as in the CSV program next door: `{{` is one `{`.
# That is the rule with the fewest cases — no backslash, no lookbehind — and
# it is the same rule the format strings of four other languages use.

function render(template: str, values: {str: str}) -> str?
    out: str @= ""
    key: str @= ""
    inside: bool @= false
    pending: bool @= false
    closing: bool @= false

    for ch in template.chars()
        if pending
            pending @ false

            if ch == "{"
                # `{{` is one literal brace.
                out @ out + "{"
                continue
            inside @ true
            key @ ""

        # The character after a single `{` is the first of the key, so it
        # falls through to the `inside` branch below rather than being
        # dropped — which is the bug the first draft of this had.
        if inside
            if ch == "}"
                inside @ false

                if key.len() == 0
                    return fail(code: "empty_key", msg: "`{}` names nothing")
                found = values[key]

                if found.is_err()
                    return fail(code: "unknown_key", msg: "the template wants `" + key + "` and it was not given")
                out @ out + found.must()
            else
                key @ key + ch
            continue

        if ch == "{"
            pending @ true
            continue

        if ch == "}"
            if closing
                # `}}` is one literal brace, symmetrically with `{{`. The
                # first draft handled only the opening half and refused
                # `{{name}}`, which is what a writer types when they mean a
                # literal `{name}` — a rule that escapes one delimiter and
                # not the other is a rule with a hole in it.
                closing @ false
                out @ out + "}"
                continue
            closing @ true
            continue

        if closing
            return fail(code: "stray_brace", msg: "a `}` with no `{` before it")
        out @ out + ch

    if closing
        return fail(code: "stray_brace", msg: "a `}` with no `{` before it")

    if inside || pending
        return fail(code: "unterminated", msg: "a `{` that never closes")

    return ok(out)

# The same, with a default for every key the map does not hold. A separate
# function and not a flag: the caller who wants this is making a different
# decision, and `spec` has no default arguments to hide it in.
function render_or(template: str, values: {str: str}, missing: str) -> str?
    filled: {str: str} @= values

    for key in needed(template)
        if filled[key].is_err()
            filled[key] @ missing

    return render(template, values: filled)

# Every key a template asks for, in the order it asks. Its own function
# because a caller often wants to know before it has the values — which is
# what turns a runtime failure into a check.
#
# ORDER: none — the order is the TEMPLATE's, read left to right, not a map
# walk. Stated rather than left out, because a reader who sees a list of keys
# beside a map is right to ask.
function needed(template: str) -> [str]
    out: [str] @= []
    key: str @= ""
    inside: bool @= false
    pending: bool @= false

    for ch in template.chars()
        if pending
            pending @ false

            if ch == "{"
                continue
            inside @ true
            key @ ""

        if inside
            if ch == "}"
                inside @ false

                if key.len() > 0
                    out @ out.push(key)
            else
                key @ key + ch
            continue

        if ch == "{"
            pending @ true

    return out

function sample_values() -> {str: str}
    values: {str: str} @= {}
    values["name"] @ "Ada"
    values["role"] @ "engineer"
    return values

function main()
    values = sample_values()

    print(render("Hello, {name}.", values: values).must())
    print(render("{name} the {role}", values: values).must())
    print(render("no holes here", values: values).must())
    print(render("a {{literal}} brace", values: values).must())
    print(render("{{name}}", values: values).must())
    print(join(needed("{name} and {role} and {name}"), ","))

    # The four refusals, each a different code for a different repair.
    print(render("Hello, {nobody}.", values: values).is_err())
    print(render("Hello, {name", values: values).is_err())
    print(render("Hello, {}.", values: values).is_err())
    print(render("Hello, name}.", values: values).is_err())

    # And the caller who would rather have a placeholder than a failure.
    print(render_or(template: "Hello, {nobody}.", values: values, missing: "friend").must())

test "a hole is filled and the text around it is untouched"
    values = sample_values()
    assert render("Hello, {name}.", values: values).must() == "Hello, Ada."
    assert render("{name}", values: values).must() == "Ada"
    assert render("{name}{role}", values: values).must() == "Adaengineer"
    assert render("a{name}b", values: values).must() == "aAdab"

test "a template with no holes is itself"
    values = sample_values()
    assert render("plain", values: values).must() == "plain"
    assert render("", values: values).must() == ""

test "a doubled brace is one brace, on both sides"
    # Both halves, because the first draft escaped only the opening one and
    # `{{name}}` — which is what a writer types for a literal `{name}` — came
    # back as `error[stray_brace]`.
    values = sample_values()
    assert render("{{name}}", values: values).must() == "{name}"
    assert render("{{}}", values: values).must() == "{}"
    assert render("a {{ b", values: values).must() == "a { b"

test "the four refusals are four different codes"
    values = sample_values()

    # A key nobody gave.
    assert render("{nobody}", values: values).is_err()

    # A brace that never closes.
    assert render("{name", values: values).is_err()
    assert render("{", values: values).is_err()

    # A hole with no name in it.
    assert render("{}", values: values).is_err()

    # A closing brace with nothing open.
    assert render("}", values: values).is_err()
    assert render("name}", values: values).is_err()

test "the keys a template needs are read in the template's own order"
    assert needed("{name} and {role}") == ["name", "role"]
    assert needed("{role} and {name}") == ["role", "name"]
    assert needed("{name} twice {name}") == ["name", "name"]
    assert needed("no holes") == []
    assert needed("{{literal}}") == []

test "the keys can be checked before the values exist"
    # The property that makes `needed` worth its own function: a caller can
    # refuse a template before it has anything to fill it with.
    values = sample_values()
    wanted = needed("Hello, {name} the {nobody}")
    unknown: [str] @= []

    for key in wanted
        if values[key].is_err()
            unknown @ unknown.push(key)

    assert unknown == ["nobody"]
    assert render("Hello, {name} the {nobody}", values: values).is_err()

test "render_or fills what is missing and leaves what is not"
    values = sample_values()
    assert render_or(template: "{name} and {nobody}", values: values, missing: "?").must() == "Ada and ?"
    assert render_or(template: "{name}", values: values, missing: "?").must() == "Ada"
    assert render_or(template: "plain", values: values, missing: "?").must() == "plain"

    # It fills a missing key and still refuses a broken template, which is
    # the line between "a value I do not have" and "a template I cannot read".
    assert render_or(template: "{name", values: values, missing: "?").is_err()
    assert render_or(template: "{}", values: values, missing: "?").is_err()

test "an empty map is not an error until a hole asks for something"
    empty: {str: str} = {}
    assert render("plain", values: empty).must() == "plain"
    assert render("{anything}", values: empty).is_err()
    assert render_or(template: "{anything}", values: empty, missing: "x").must() == "x"
examples/template/main.hero, 235 lines

fingerprint sha256 4242465e775d · 2 files · index.json

Next: rpn. All the examples. ← Previous: ini

Run one yourself: the four commands. Learn the language, or read the full specification in one file.