Choosing Placeholder Syntax for Your Text

The default ${var:name|trim} form is easy to recognize in ordinary text. It may be awkward when the surrounding format already gives ${...} another meaning, or when a label must show that sequence literally. ReplacerOptions lets you choose the expression frame, the two separators, and the escape mode before constructing a Replacer.

This page explains each choice and the situations that can make a replacement surprising. The preceding pages introduce Completing Text with Placeholders and show how to Providing Your Own Placeholder Values and Filters.

Opening and Closing Placeholder Delimiters

setFrame(begin, end) changes the opening and closing strings. For a gallery caption that already contains dollar signs, [[var=movement/upper]] can be easier to distinguish from other text. In that expression, [[ starts the request and ]] ends it.

text/Placeholders/PlaceholderDemos.cpp

void chooseSyntax() {
    auto options = el::placeholder::ReplacerOptions{};
    options.setFrame("[["_el, "]]"_el).setNameSeparator("="_el).setFilterSeparator("/"_el);
    auto replacer = el::placeholder::Replacer{options};
    replacer.setVariableSource({{{"movement"_el, u8"Εξπρεσιονισμός"_el}}});
    replacer.addTextFilters();

    el::io::printLine(replacer.replaceOrThrow("[[var=movement/upper]]"_el));
}

Output

ΕΞΠΡΕΣΙΟΝΙΣΜΌΣ

Each frame string must contain 1–16 Unicode code points. An empty frame or a longer one raises ParameterFailure when you set it. The options are checked again when the replacer is constructed, because some combinations conflict with the selected escape mode.

Filter Separators and Disabled Filter Chains

setFilterSeparator() changes what divides filters. The example uses / so [[var=movement/upper]] reads the variable and then runs upper. The default separator is |.

A filter separator may contain 0–16 code points. Setting it to an empty string disables filter chains, which is useful when the text format should allow only direct values. With filters disabled, a filter-like character in a source parameter is ordinary parameter text unless it conflicts with the closing frame.

Name and Parameter Separators

setNameSeparator() changes the boundary after each source or filter name. The default : makes ${var:movement}; the example changes it to = for [[var=movement]]. The same separator applies to filter parameters, such as [[var=movement/escape=format=html]].

Like the filter separator, it may contain 0–16 code points. An empty name separator disables parameters altogether. Use that only when every registered source and filter can work with an empty parameter, because the replacer then has no way to pass a value such as movement to var.

The delimiters may overlap. At a position where more than one matches, the longest matching delimiter wins. For equal lengths, the closing frame has priority over the filter separator, which has priority over the name separator. Distinct delimiters are usually easier for the people who will edit the text.

Escaping Literal Placeholder Delimiters

setEscapeMode() chooses how an author writes a delimiter without starting or ending an expression. The default Backslash mode lets a backslash before the opening frame keep it literal. Outside an expression, \${var:movement} becomes the literal ${var:movement}, and \\ becomes one backslash. Other outside backslashes are preserved.

Inside an expression, a backslash can quote the first code point of the closing frame or filter separator. For example, ${var:name\|part} passes name|part as one source parameter, while ${var:name\}part} passes name}part. A doubled backslash produces one literal backslash. This quoting acts on the placeholder syntax, before the provider interprets its parameter.

In Double mode, repeat the first code point of the opening frame to show that frame literally. With the default frame, $${var:movement} becomes ${var:movement}. Inside an expression, repeat the first code point of the closing frame or filter separator: || supplies a literal | to a parameter. This mode helps when a backslash already has special meaning in the surrounding text format.

text/Placeholders/PlaceholderDemos.cpp

void chooseEscapeMode() {
    auto backslash = el::placeholder::Replacer{};
    backslash.setVariableSource({{{"movement"_el, u8"Κυβισμός"_el}}});
    el::io::printLine(backslash.replaceOrThrow("Literal: \\${var:movement}; value: ${var:movement}"_el));

    auto options = el::placeholder::ReplacerOptions{};
    options.setEscapeMode(el::placeholder::EscapeMode::Double);
    auto doubled = el::placeholder::Replacer{options};
    doubled.setVariableSource({{{"movement"_el, u8"Κυβισμός"_el}}});
    el::io::printLine(doubled.replaceOrThrow("Literal: $${var:movement}; value: ${var:movement}"_el));
}

Output

Literal: ${var:movement}; value: Κυβισμός
Literal: ${var:movement}; value: Κυβισμός

In double mode, no configured delimiter may start with two copies of its first code point. For example, [[ cannot be the opening frame in that mode: its first [ could mean either the delimiter or its escape. Constructing the replacer with such options raises LogicFailure.

Strict and Tolerant Placeholder Replacement

Strict replacement makes a final document predictable: replaceOrThrow() reports the first failed expression. Tolerant replace() is useful when someone is editing a draft; it keeps a failed expression and continues to later ones. The following example shows both policies on the same input.

text/Placeholders/PlaceholderDemos.cpp

void handleInvalidExpression() {
    auto replacer = el::placeholder::Replacer{};
    replacer.setVariableSource({{{"movement"_el, u8"Ρεαλισμός"_el}}});
    const auto draft = "${var:missing}; ${var:movement}"_el;

    el::io::printLine("Draft: "_el, replacer.replace(draft));
    try {
        [[maybe_unused]] const auto result = replacer.replaceOrThrow(draft);
    } catch (const el::placeholder::ReplacerError &error) {
        el::io::printLine("Strict replacement failed: "_el, error.reason());
    }
}

Output

Draft: ${var:missing}; Ρεαλισμός
Strict replacement failed: Placeholder variable is not defined: missing

A missing closing frame leaves the expression literal through its line or the end of input in tolerant mode. Expressions cannot cross a physical line, contain a nested opening frame, or have more than 16 filters. validate() and validateOrThrow() check the expression and provider parameters before replacement, but they cannot guarantee that a live source will still provide a value later.

Single and Repeated Replacement Passes

A replacement is inserted as literal text and is never scanned for more placeholders. That prevents a source from introducing a new placeholder request. It also means an intentional second replacement pass can change the result: if a source returns ${var:other}, a second call may interpret it. Normally, complete each text value in one pass.

Escaping Replacement Values and Parsing Layers

The replacer does not automatically escape a source value for its destination. If a value is inserted into HTML, JSON, or another language, use an appropriate text filter or encode it at the destination boundary. For example, the built-in escape:format=html filter prepares text for HTML content, while another destination needs its own escape format. Do not mistake quoting the expression delimiters for escaping the replacement value.

The same care matters with layers of text parsing. A C++ string literal, an ELCL quoted value, and the replacer each interpret different characters. An extra backslash may be consumed by an earlier layer before the replacer sees it. When placeholders occur in ELCL, follow Using Placeholders in Configuration Text for that parser’s escape rules. For ordinary runtime strings, test the exact text you pass to the replacer and choose one escape mode for the whole document.

Complete demo sources

text/Placeholders/PlaceholderDemos.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "PlaceholderDemos.hpp"

#include <erbsland/EnvironmentVariables.hpp>
#include <erbsland/placeholder/EscapeMode.hpp>
#include <erbsland/placeholder/Replacer.hpp>
#include <erbsland/placeholder/ReplacerError.hpp>
#include <erbsland/placeholder/ReplacerOptions.hpp>
#include <erbsland/StandardStreams.hpp>

namespace demo {

/// Expand a gallery label with application values and a text filter.
///
/// `Replacer` reads a source name, its parameter, and optional filters from each expression. The variable source
/// supplies application text; filters process it from left to right. The surrounding text remains unchanged.
void replaceGalleryLabel() {
    auto replacer = el::placeholder::Replacer{};
    replacer.setVariableSource({{{"movement"_el, u8"Ιμπρεσιονισμός"_el}}});
    replacer.addTextFilters();

    const auto label = replacer.replaceOrThrow(u8"Gallery: ${var:movement} (${var:movement|upper})"_el);
    el::io::printLine(label);
}

/// Combine built-in application variables, environment values, and text filters.
///
/// The `var` source resolves a fixed map. The `env` source reads a process environment variable when replacement
/// runs. The `required` source flag reports an absent variable instead of returning `undefined`.
void useBuiltInProviders() {
    auto environment = el::system::EnvironmentVariables{};
    environment.setOrThrow("ERBSLAND_DEMO_ART_MOVEMENT"_el, u8"Ντανταϊσμός"_el);
    auto replacer = el::placeholder::Replacer{};
    replacer.setVariableSource({{{"movement"_el, u8"  Κυβισμός  "_el}}});
    replacer.addEnvironmentSource();
    replacer.addTextFilters();

    el::io::printLine(replacer.replaceOrThrow("Movement: ${var:movement|trim}"_el));
    el::io::printLine(replacer.replaceOrThrow("Next movement: ${env:ERBSLAND_DEMO_ART_MOVEMENT,required}"_el));
    environment.removeOrThrow("ERBSLAND_DEMO_ART_MOVEMENT"_el);
}

/// Change the placeholder frame and separators to fit a text format.
///
/// `ReplacerOptions` belongs to a replacer at construction. Empty separators disable the corresponding syntax:
/// without a name separator, providers receive an empty parameter; without a filter separator, no chain is parsed.
void chooseSyntax() {
    auto options = el::placeholder::ReplacerOptions{};
    options.setFrame("[["_el, "]]"_el).setNameSeparator("="_el).setFilterSeparator("/"_el);
    auto replacer = el::placeholder::Replacer{options};
    replacer.setVariableSource({{{"movement"_el, u8"Εξπρεσιονισμός"_el}}});
    replacer.addTextFilters();

    el::io::printLine(replacer.replaceOrThrow("[[var=movement/upper]]"_el));
}

/// Write a literal frame using backslash or doubled-delimiter escaping.
///
/// Escape mode changes how delimiters are quoted in the input. It does not escape the returned source text or
/// interpret it a second time.
void chooseEscapeMode() {
    auto backslash = el::placeholder::Replacer{};
    backslash.setVariableSource({{{"movement"_el, u8"Κυβισμός"_el}}});
    el::io::printLine(backslash.replaceOrThrow("Literal: \\${var:movement}; value: ${var:movement}"_el));

    auto options = el::placeholder::ReplacerOptions{};
    options.setEscapeMode(el::placeholder::EscapeMode::Double);
    auto doubled = el::placeholder::Replacer{options};
    doubled.setVariableSource({{{"movement"_el, u8"Κυβισμός"_el}}});
    el::io::printLine(doubled.replaceOrThrow("Literal: $${var:movement}; value: ${var:movement}"_el));
}

/// Choose strict replacement for a required label and tolerant replacement for an editable draft.
///
/// `validate()` checks syntax and provider parameters. `replace()` preserves a failed expression and keeps scanning;
/// `replaceOrThrow()` reports the first problem with a category and input offset.
void handleInvalidExpression() {
    auto replacer = el::placeholder::Replacer{};
    replacer.setVariableSource({{{"movement"_el, u8"Ρεαλισμός"_el}}});
    const auto draft = "${var:missing}; ${var:movement}"_el;

    el::io::printLine("Draft: "_el, replacer.replace(draft));
    try {
        [[maybe_unused]] const auto result = replacer.replaceOrThrow(draft);
    } catch (const el::placeholder::ReplacerError &error) {
        el::io::printLine("Strict replacement failed: "_el, error.reason());
    }
}

}