Working with Character Sets

A configuration key may allow letters, digits, underscores, and hyphens, while a display label may accept letters from many writing systems. CharSet lets you give such a character policy a name and reuse it for validation, searching, trimming, and removal. You can describe a few explicit characters, Unicode categories, or a combination of smaller sets.

This page develops those policies from simple sets, applies them to incoming text, and shows how the same definitions support cleanup and word conversion. Each set contains decoded Unicode code points, represented by Char. A visible character can contain several code points, so a set describes individual membership rather than complete words or user-perceived characters.

Defining a Reusable Character Policy

A named set makes the rule visible where you apply it. The following option-name policy accepts ASCII letters, digits, underscores, and hyphens. containsOnly() checks every decoded character against that set:

text/String/CharacterSetReusable.cpp

/// Reuse named `CharSet` objects for validation policies that are applied repeatedly.
///
/// Building a set once makes the policy easier to read and avoids reconstructing category or pattern based sets in hot
/// paths.
void characterSetReusable() {
    static const auto optionNameChars = el::CharSet::fromPattern("-_a-zA-Z0-9"_el);

    const auto optionNames = el::StringList{
        "orbite-07"_el,
        "antenne_nord"_el,
        "équipe-science"_el,
        "module solaire"_el,
    };

    const auto yesNo = el::BooleanFormat::yesNo();
    optionNames.forEach([&](const el::String &optionName) -> void {
        el::io::printLine(optionName, " -> "_el, yesNo, optionName.containsOnly(optionNameChars));
    });
}

Output

orbite-07 -> yes
antenne_nord -> yes
équipe-science -> no
module solaire -> no

Keeping this repeated policy in a local static const avoids rebuilding it on each call. That is especially useful for sets assembled from Unicode categories. CharSet values are inexpensive to copy, so you can also store a policy in an object or pass it to another component.

Explicit Characters and Ranges

For punctuation or separators, a literal or list of Char values directly expresses the accepted characters. The text used to construct a set contributes individual code points: "!?" means either punctuation mark, regardless of order or repetition in the input.

text/String/CharacterSetText.cpp

/// `CharSet` can be created from text or from a list of decoded characters.
///
/// Duplicate characters are ignored, and the resulting set is normalized for efficient membership tests.
void characterSetText() {
    auto punctuation = el::CharSet{"!?.,;"_el};
    auto separators = el::CharSet{U',', U';', U':', U'/'};

    const auto message = el::String{"statut: prêt; orbite stable."_el};
    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Message contains punctuation : "_el, yesNo, message.containsOneOf(punctuation));
    el::io::printLine("Message contains separators .: "_el, yesNo, message.containsOneOf(separators));
    el::io::printLine("Separator accepts '/' .......: "_el, yesNo, separators.contains(U'/'));
}

Output

Message contains punctuation : yes
Message contains separators .: yes
Separator accepts '/' .......: yes

A range is useful when the accepted code points are consecutive. CharSet::fromRange() includes both endpoints, whereas CharSet{U'a', U'z'} contains only those two characters:

text/String/CharacterSetCharacters.cpp

/// `CharSet` can be created directly from a single character or explicitly from an inclusive range.
///
/// Use `fromRange()` when two `Char` values describe bounds instead of two individual allowed characters.
void characterSetCharacters() {
    auto questionMark = el::CharSet{U'?'};
    auto asciiLowercase = el::CharSet::fromRange(U'a', U'z');

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Question marker accepts '?' : "_el, yesNo, questionMark.contains(U'?'));
    el::io::printLine("Lowercase accepts 'm' ......: "_el, yesNo, asciiLowercase.contains(U'm'));
    el::io::printLine("Lowercase accepts 'M' ......: "_el, yesNo, asciiLowercase.contains(U'M'));
}

Output

Question marker accepts '?' : yes
Lowercase accepts 'm' ......: yes
Lowercase accepts 'M' ......: no

Patterns for Compact Policies

CharSet::fromPattern() combines literal characters and inclusive ranges in a compact spelling. A hyphen between characters describes a range; at the start or end it represents a literal hyphen. The syntax resembles a regular-expression character class, but it describes only membership, without repetitions or multi-character matches.

A leading ^ inverts the policy: ^-_a-zA-Z0-9 selects Unicode scalar values outside the option-name set. The hyphen after that prefix is still a literal hyphen. This is useful when a cleanup operation should remove every character that the policy would reject:

text/String/CharacterSetPatterns.cpp

/// `CharSet::fromPattern()` creates compact character sets from literal and range patterns.
///
/// A hyphen between two characters defines a range.
/// A leading or trailing hyphen is treated as a literal hyphen.
/// A leading caret selects every Unicode scalar value outside the following set.
void characterSetPatterns() {
    auto identifierChars = el::CharSet::fromPattern("_a-zA-Z0-9"_el);
    auto optionNameChars = el::CharSet::fromPattern("-_a-zA-Z0-9"_el);

    const auto stationId = el::String{"ORBIT_07"_el};
    const auto optionName = el::String{"orbite-07"_el};
    const auto spacedName = el::String{"orbite 07"_el};

    const auto invalidOptionChars = el::CharSet::fromPattern("^-_a-zA-Z0-9"_el);
    el::io::printLine("Filtered option ...........: "_el, spacedName.removedAll(invalidOptionChars));

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Identifier \"", stationId, "\" ....: "_el, yesNo, stationId.containsOnly(identifierChars));
    el::io::printLine("Option \"", optionName, "\" ........: "_el, yesNo, optionName.containsOnly(optionNameChars));
    el::io::printLine("Option \"", spacedName, "\" ........: "_el, yesNo, spacedName.containsOnly(optionNameChars));
}

Output

Filtered option ...........: orbite07
Identifier "ORBIT_07" ....: yes
Option "orbite-07" ........: yes
Option "orbite 07" ........: no

The filtered name can differ from what the user supplied. For a configuration key whose exact spelling matters, report an invalid character instead of silently filtering it. Malformed patterns such as the reversed range z-a raise ParseError.

ASCII and Unicode Character Categories

Many formats already have a named ASCII rule, such as hexadecimal digits or words containing hyphens. An AsciiCategory can be passed directly to containsOnly() and to the scanning methods of StringCharReader. A single Char can test the same rule with isAsciiCategory(). Construct a set from that category when you need to combine it with custom characters or pass it to a set-based API.

For text spanning multiple writing systems, CharSet::from() accepts a UnicodeCategory or UnicodeCategoryGroup. The following example contrasts an ASCII packet identifier with reusable sets of Unicode digits and letters:

text/String/CharacterSetCategories.cpp

/// Use `AsciiCategory` directly when an API accepts it, and construct a `CharSet` only when the policy must be retained
/// or combined with other characters.
void characterSetCategories() {
    const auto packetId = el::String{"SENSOR-07"_el};
    const auto packetIdIsValid = packetId.containsOnly(el::AsciiCategory::WordWithHyphen);
    const auto firstIsUppercase =
        packetId.charAt(el::StringSide::Front).isAsciiCategory(el::AsciiCategory::UppercaseLetter);

    static const auto unicodeDigits = el::CharSet::from(el::UnicodeCategory::DecimalNumber);
    static const auto unicodeLetters = el::CharSet::from(el::UnicodeCategoryGroup::Letter);

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Packet-id is ASCII word ..: "_el, yesNo, packetIdIsValid);
    el::io::printLine("First letter is uppercase : "_el, yesNo, firstIsUppercase);
    el::io::printLine("Unicode digit accepts '7' : "_el, yesNo, unicodeDigits.contains(U'7'));
    el::io::printLine("Unicode letter accepts 'é': "_el, yesNo, unicodeLetters.contains(U'é'));
}

Output

Packet-id is ASCII word ..: yes
First letter is uppercase : yes
Unicode digit accepts '7' : yes
Unicode letter accepts 'é': yes

A Unicode letter category includes accented letters such as é. A decomposed spelling may also contain combining marks, which belong to different categories. Choose the accepted categories and, if needed, a normalization policy according to the format you are implementing.

Validating Incoming Text

Character membership and encoding validity answer different questions. Decoded string operations tolerate malformed input by presenting replacement characters in many inspection operations. When your input must be well-formed, first call isEncodingValid() or encodingValidOrThrow(). These checks are available for all three string widths. Then apply any length limits and character policies to the validated value.

containsOnly(allowed) checks that every character belongs to an accepted set. containsOneOf() answers whether any character belongs to a selected set, which is useful for identifying a specific validation problem:

text/String/ValidateCharacterPolicy.cpp

/// Combine encoding checks with `containsOnly()` and `containsOneOf()` for character-level validation.
///
/// Validate externally supplied text before applying tolerant decoded-character operations.
void validateCharacterPolicy() {
    static const auto userNameChars = el::CharSet::fromPattern("-_a-zA-Z0-9"_el);
    static const auto forbiddenChars = el::CharSet{" \t\r\n"_el};

    const auto userNames = el::StringList{
        "orbite-07"_el,
        "module solaire"_el,
        "équipe-science"_el,
    };

    const auto yesNo = el::BooleanFormat::yesNo();
    userNames.forEach([&](const el::String &userName) -> void {
        const auto isAccepted = userName.isEncodingValid() && userName.containsOnly(userNameChars) &&
            !userName.containsOneOf(forbiddenChars);
        el::io::printLine(userName, " -> "_el, yesNo, isAccepted);
    });
}

Output

orbite-07 -> yes
module solaire -> no
équipe-science -> no

The example’s explicit whitespace check shows how to test a forbidden set; its allowed set already excludes those characters. In a validator with several diagnostics, separate checks can explain why a value failed. For a single acceptance decision, the encoding check and allowed-set check are sufficient here.

An empty string satisfies containsOnly() because it has no character outside the set. Check isEmpty() separately when a value is required. A default-constructed CharSet contains no characters: only empty text passes containsOnly() with it, and containsOneOf() always returns false.

Membership alone does not establish a complete grammar. An identifier may restrict its first character, an address may require delimiters, and a field may have a length limit. Apply those structural rules alongside the sets; Character Access and Parsing of Strings explains how to read fields and sequences of characters.

Combining Policies for Different Positions

An identifier often accepts fewer characters at its start than in the remaining positions. You can build the continuation policy from the start policy, adding digits and a hyphen without duplicating the letter rule:

text/String/CombineCharacterSets.cpp

/// `CharSet` objects can be combined and compared to express larger validation policies.
///
/// Use set operations to build the final policy from named parts, then use subset checks when one policy must remain
/// inside another.
void combineCharacterSets() {
    static const auto letters = el::CharSet::from(el::UnicodeCategoryGroup::Letter);
    static const auto digits = el::CharSet::from(el::UnicodeCategory::DecimalNumber);
    static const auto identifierStart = letters | el::CharSet{U'_'};
    static const auto identifierContinue = identifierStart | digits | el::CharSet{U'-'};

    const auto identifier = el::String{"orbite-7"_el};
    const auto firstCharacterOk = identifierStart.contains(identifier.charAt(el::StringSide::Front));
    const auto fullIdentifierOk = identifier.containsOnly(identifierContinue);

    const auto invalidContinue = identifierContinue.inverted();
    el::io::printLine("Filtered identifier .......: "_el, el::String{"orbite !7"_el}.removedAll(invalidContinue));

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Start policy is subset ....: "_el, yesNo, identifierStart.isSubsetOf(identifierContinue));
    el::io::printLine("First character accepted ..: "_el, yesNo, firstCharacterOk);
    el::io::printLine("Identifier accepted .......: "_el, yesNo, fullIdentifierOk);
}

Output

Filtered identifier .......: orbite7
Start policy is subset ....: yes
First character accepted ..: yes
Identifier accepted .......: yes

The union operator | accepts characters from either set. Its named equivalent is unitedWith(). Intersection with & or intersectedWith() keeps only characters accepted by both policies, while subtraction with - or subtractedBy() removes exceptions. Choose the form that makes the policy easiest to read.

The example checks the first character separately, then checks the whole value against the broader continuation set. isSubsetOf() can also verify a relationship between policies, for example when a configured extension must stay inside the format’s accepted alphabet.

Trimming Borders and Removing Unwanted Characters

A character policy can select text to remove as well as text to accept. trimmed() removes matching characters from the edges while leaving the interior untouched. Without a set it trims ASCII whitespace; a custom set can also include application-specific markers:

text/String/TrimCharacterSet.cpp

/// `String::trimmed()` returns a view with selected characters removed from the front, back, or both sides.
///
/// With a custom `CharSet`, trimming is not limited to whitespace.
void trimCharacterSet() {
    const auto raw = el::String{"*** signal-orbite ;; "_el};
    static const auto border = el::CharSet{" *;"_el};

    auto clean = raw.trimmed(border);
    auto frontOnly = raw.trimmed(border, el::StringSide::Front);
    auto backOnly = raw.trimmed(border, el::StringSide::Back);

    el::io::printLine("Raw .......: \"", raw, "\""_el);
    el::io::printLine("Both sides : \"", clean, "\""_el);
    el::io::printLine("Front only : \"", frontOnly, "\""_el);
    el::io::printLine("Back only .: \"", backOnly, "\""_el);
}

Output

Raw .......: "*** signal-orbite ;; "
Both sides : "signal-orbite"
Front only : "signal-orbite ;; "
Back only .: "*** signal-orbite"

The result is an owning slice into the original storage, so the selected text stays alive independently of the source variable. The optional StringSide limits trimming to the front or back.

When selected characters can appear throughout the text, removedAll() omits every matching code point. Inverting an allowed set with ~allowed or inverted() selects all other Unicode scalar values:

text/String/RemoveCharacters.cpp

/// `String::removedAll()` removes every decoded character from a selected `CharSet`.
///
/// This is useful for simple cleanup passes where unwanted characters may occur anywhere in the text.
/// Inverting an allowed set removes every character outside that policy.
void removeCharacters() {
    static const auto controlChars = el::CharSet::from(el::AsciiCategory::Control);

    const auto input = el::String{"rapport\torbite\nstable"_el};
    auto logLine = input.removedAll(controlChars);

    static const auto allowedChars = el::CharSet::fromPattern(" a-z"_el);
    const auto stationName = el::String{"orbite! 07"_el};
    el::io::printLine("Allowed  : "_el, stationName.removedAll(~allowedChars));

    el::io::printLine("Original : "_el, input.toEscaped(el::EscapeFormat::Cpp));
    el::io::printLine("Cleaned  : "_el, logLine.toEscaped(el::EscapeFormat::Cpp));
}

Output

Allowed  : orbite
Original : rapport\torbite\nstable
Cleaned  : rapportorbitestable

Removing tabs and newlines also removes the boundaries they represented: rapport\torbite\nstable becomes rapportorbitestable. When those boundaries should remain as spaces, toWhitespaceSimplified() expresses the intended cleanup more accurately. Set inversion concerns Unicode scalar values; encoding validation remains a separate step.

Character Sets in Word Transformations

The common transformation helpers accept sets where the caller defines word separators. For example, a sensor format may treat underscores and hyphens as equivalent boundaries:

static const auto separators = el::CharSet{"_-"_el};
const auto sensorKey = el::String{"sonda_CTD-mare"_el};
const auto joined = el::toCamelCaseFromWords(sensorKey, separators); // sondaCTDMare

Include <erbsland/StringTransformations.hpp> for the helper. Each character in the set is a boundary, repeated boundaries are skipped, and the input remains unchanged. The default overload uses whitespace, so an explicit set records the format’s additional rule.

Transforming Strings develops common case conversion, word conversion, and whitespace cleanup with compiled examples. It also explains transformed() for custom character mappings when replacement or removal by a set cannot express the rule.

Complete demo sources

text/String/CharacterSetReusable.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringList.hpp>

namespace demo {

/// Reuse named `CharSet` objects for validation policies that are applied repeatedly.
///
/// Building a set once makes the policy easier to read and avoids reconstructing category or pattern based sets in hot
/// paths.
void characterSetReusable() {
    static const auto optionNameChars = el::CharSet::fromPattern("-_a-zA-Z0-9"_el);

    const auto optionNames = el::StringList{
        "orbite-07"_el,
        "antenne_nord"_el,
        "équipe-science"_el,
        "module solaire"_el,
    };

    const auto yesNo = el::BooleanFormat::yesNo();
    optionNames.forEach([&](const el::String &optionName) -> void {
        el::io::printLine(optionName, " -> "_el, yesNo, optionName.containsOnly(optionNameChars));
    });
}

}

text/String/CharacterSetText.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>

namespace demo {

/// `CharSet` can be created from text or from a list of decoded characters.
///
/// Duplicate characters are ignored, and the resulting set is normalized for efficient membership tests.
void characterSetText() {
    auto punctuation = el::CharSet{"!?.,;"_el};
    auto separators = el::CharSet{U',', U';', U':', U'/'};

    const auto message = el::String{"statut: prêt; orbite stable."_el};
    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Message contains punctuation : "_el, yesNo, message.containsOneOf(punctuation));
    el::io::printLine("Message contains separators .: "_el, yesNo, message.containsOneOf(separators));
    el::io::printLine("Separator accepts '/' .......: "_el, yesNo, separators.contains(U'/'));
}

}

text/String/CharacterSetCharacters.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>

namespace demo {

/// `CharSet` can be created directly from a single character or explicitly from an inclusive range.
///
/// Use `fromRange()` when two `Char` values describe bounds instead of two individual allowed characters.
void characterSetCharacters() {
    auto questionMark = el::CharSet{U'?'};
    auto asciiLowercase = el::CharSet::fromRange(U'a', U'z');

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Question marker accepts '?' : "_el, yesNo, questionMark.contains(U'?'));
    el::io::printLine("Lowercase accepts 'm' ......: "_el, yesNo, asciiLowercase.contains(U'm'));
    el::io::printLine("Lowercase accepts 'M' ......: "_el, yesNo, asciiLowercase.contains(U'M'));
}

}

text/String/CharacterSetPatterns.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>

namespace demo {

/// `CharSet::fromPattern()` creates compact character sets from literal and range patterns.
///
/// A hyphen between two characters defines a range.
/// A leading or trailing hyphen is treated as a literal hyphen.
/// A leading caret selects every Unicode scalar value outside the following set.
void characterSetPatterns() {
    auto identifierChars = el::CharSet::fromPattern("_a-zA-Z0-9"_el);
    auto optionNameChars = el::CharSet::fromPattern("-_a-zA-Z0-9"_el);

    const auto stationId = el::String{"ORBIT_07"_el};
    const auto optionName = el::String{"orbite-07"_el};
    const auto spacedName = el::String{"orbite 07"_el};

    const auto invalidOptionChars = el::CharSet::fromPattern("^-_a-zA-Z0-9"_el);
    el::io::printLine("Filtered option ...........: "_el, spacedName.removedAll(invalidOptionChars));

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Identifier \"", stationId, "\" ....: "_el, yesNo, stationId.containsOnly(identifierChars));
    el::io::printLine("Option \"", optionName, "\" ........: "_el, yesNo, optionName.containsOnly(optionNameChars));
    el::io::printLine("Option \"", spacedName, "\" ........: "_el, yesNo, spacedName.containsOnly(optionNameChars));
}

}

text/String/CharacterSetCategories.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/AsciiCategory.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringSide.hpp>
#include <erbsland/UnicodeCategory.hpp>
#include <erbsland/UnicodeCategoryGroup.hpp>

namespace demo {

/// Use `AsciiCategory` directly when an API accepts it, and construct a `CharSet` only when the policy must be retained
/// or combined with other characters.
void characterSetCategories() {
    const auto packetId = el::String{"SENSOR-07"_el};
    const auto packetIdIsValid = packetId.containsOnly(el::AsciiCategory::WordWithHyphen);
    const auto firstIsUppercase =
        packetId.charAt(el::StringSide::Front).isAsciiCategory(el::AsciiCategory::UppercaseLetter);

    static const auto unicodeDigits = el::CharSet::from(el::UnicodeCategory::DecimalNumber);
    static const auto unicodeLetters = el::CharSet::from(el::UnicodeCategoryGroup::Letter);

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Packet-id is ASCII word ..: "_el, yesNo, packetIdIsValid);
    el::io::printLine("First letter is uppercase : "_el, yesNo, firstIsUppercase);
    el::io::printLine("Unicode digit accepts '7' : "_el, yesNo, unicodeDigits.contains(U'7'));
    el::io::printLine("Unicode letter accepts 'é': "_el, yesNo, unicodeLetters.contains(U'é'));
}

}

text/String/ValidateCharacterPolicy.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringList.hpp>

namespace demo {

/// Combine encoding checks with `containsOnly()` and `containsOneOf()` for character-level validation.
///
/// Validate externally supplied text before applying tolerant decoded-character operations.
void validateCharacterPolicy() {
    static const auto userNameChars = el::CharSet::fromPattern("-_a-zA-Z0-9"_el);
    static const auto forbiddenChars = el::CharSet{" \t\r\n"_el};

    const auto userNames = el::StringList{
        "orbite-07"_el,
        "module solaire"_el,
        "équipe-science"_el,
    };

    const auto yesNo = el::BooleanFormat::yesNo();
    userNames.forEach([&](const el::String &userName) -> void {
        const auto isAccepted = userName.isEncodingValid() && userName.containsOnly(userNameChars) &&
            !userName.containsOneOf(forbiddenChars);
        el::io::printLine(userName, " -> "_el, yesNo, isAccepted);
    });
}

}

text/String/CombineCharacterSets.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringSide.hpp>
#include <erbsland/UnicodeCategory.hpp>
#include <erbsland/UnicodeCategoryGroup.hpp>

namespace demo {

/// `CharSet` objects can be combined and compared to express larger validation policies.
///
/// Use set operations to build the final policy from named parts, then use subset checks when one policy must remain
/// inside another.
void combineCharacterSets() {
    static const auto letters = el::CharSet::from(el::UnicodeCategoryGroup::Letter);
    static const auto digits = el::CharSet::from(el::UnicodeCategory::DecimalNumber);
    static const auto identifierStart = letters | el::CharSet{U'_'};
    static const auto identifierContinue = identifierStart | digits | el::CharSet{U'-'};

    const auto identifier = el::String{"orbite-7"_el};
    const auto firstCharacterOk = identifierStart.contains(identifier.charAt(el::StringSide::Front));
    const auto fullIdentifierOk = identifier.containsOnly(identifierContinue);

    const auto invalidContinue = identifierContinue.inverted();
    el::io::printLine("Filtered identifier .......: "_el, el::String{"orbite !7"_el}.removedAll(invalidContinue));

    const auto yesNo = el::BooleanFormat::yesNo();
    el::io::printLine("Start policy is subset ....: "_el, yesNo, identifierStart.isSubsetOf(identifierContinue));
    el::io::printLine("First character accepted ..: "_el, yesNo, firstCharacterOk);
    el::io::printLine("Identifier accepted .......: "_el, yesNo, fullIdentifierOk);
}

}

text/String/TrimCharacterSet.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringSide.hpp>

namespace demo {

/// `String::trimmed()` returns a view with selected characters removed from the front, back, or both sides.
///
/// With a custom `CharSet`, trimming is not limited to whitespace.
void trimCharacterSet() {
    const auto raw = el::String{"*** signal-orbite ;; "_el};
    static const auto border = el::CharSet{" *;"_el};

    auto clean = raw.trimmed(border);
    auto frontOnly = raw.trimmed(border, el::StringSide::Front);
    auto backOnly = raw.trimmed(border, el::StringSide::Back);

    el::io::printLine("Raw .......: \"", raw, "\""_el);
    el::io::printLine("Both sides : \"", clean, "\""_el);
    el::io::printLine("Front only : \"", frontOnly, "\""_el);
    el::io::printLine("Back only .: \"", backOnly, "\""_el);
}

}

text/String/RemoveCharacters.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/AsciiCategory.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/EscapeFormat.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>

namespace demo {

/// `String::removedAll()` removes every decoded character from a selected `CharSet`.
///
/// This is useful for simple cleanup passes where unwanted characters may occur anywhere in the text.
/// Inverting an allowed set removes every character outside that policy.
void removeCharacters() {
    static const auto controlChars = el::CharSet::from(el::AsciiCategory::Control);

    const auto input = el::String{"rapport\torbite\nstable"_el};
    auto logLine = input.removedAll(controlChars);

    static const auto allowedChars = el::CharSet::fromPattern(" a-z"_el);
    const auto stationName = el::String{"orbite! 07"_el};
    el::io::printLine("Allowed  : "_el, stationName.removedAll(~allowedChars));

    el::io::printLine("Original : "_el, input.toEscaped(el::EscapeFormat::Cpp));
    el::io::printLine("Cleaned  : "_el, logLine.toEscaped(el::EscapeFormat::Cpp));
}

}