Dom Subsystem
ClaFi Framework Developer Manual
Introduction
The Dom subsystem is a generic, type-safe, schema-first representation of any hierarchical data. It provides automatic save capabilities, customizable file formats, and transactional changes.
import ClaFi.Dom;If you need built-in file format support, also import the corresponding format module:
import ClaFi.Dom.Formats.ClaFi; // or ClaFi.Dom.Formats.Json
using namespace ClaFi::Dom;Blueprint construction nodes are explicitly qualified with the
Dt:: namespace prefix to clearly differentiate them from active runtime memory nodes.
Document is simply a Section that can be saved to and loaded from disk. Any Section can be combined and nested with any other section, in any order, whether or not it's a Document's root. A Document can be stored in any file format — the format is a separate, pluggable concern.
Basic Usage Example
The Dom subsystem utilizes a declarative DSL blueprint pattern. This example shows how to initialize a serialized Document container on disk, bind native class methods as event listeners, load, alter, and save configuration or document settings.
import ClaFi.Dom;
import ClaFi.Dom.Formats.ClaFi;
class MyForm
{
public:
void onColorChanged(ChangeEvent&);
void configShowCase()
{
// 1. Create the document and pass its layout blueprint to the constructor.
// The outermost Dt::Section is anonymous (no key), acting as the document root.
Document<FileFormat::ClaFi> configDoc
{
L"app.cfg",
AutoSave::No,
Dt::Section
{
Dt::Section
{
L"Window",
Dt::Value{ L"Width", 1280 },
Dt::Value{ L"Height", 720 },
Dt::Value{ L"Title", L"My Application" }
},
Dt::Value
{
L"ColorMode",
ColorMode::Dark,
Dt::OnChange{ this, &MyForm::onColorChanged }
}
}
};
// 2. Load from disk
configDoc.load();
// 3. Access and modify data
int width = (configDoc / L"Window" / L"Width").get<int>();
width += 100;
(configDoc / L"Window" / L"Width").set(width);
// 4. Save back out
configDoc.save();
}
};
Type Registration
Before using custom types within blueprints, the developer must declare their serialization rules. The system provides mapping architectures for primitives, structs (or classes), and enums.
Scalar Values
Basic types (int, float, bool, std::wstring) come with serializers, imported as described in Where a Serializer Must Be Imported. Custom scalar types can be registered by specializing the single-parameter template ScalarSerializer.
Each custom serializer can optionally expose an internal StorageType alias. Declaring this alias is only required when the type you pass to your serializer differs from the type that should be permanently stored in the DOM. For example, string-like views (such as std::wstring_view or const wchar_t*) use this mechanism to declare using StorageType = std::wstring;, forcing the DOM engine to instantiate an owned std::wstring node and preventing dangling pointer bugs. If no StorageType alias is declared, the engine seamlessly falls back to the decayed type of your template parameter.
template <>
struct ClaFi::Dom::ScalarSerializer<MyIntType>
{
// Optional: omitted here as StorageType defaults directly to MyIntType
static std::wstring toWString(const MyIntType& v)
{
return std::to_wstring(v.val);
}
static void fromWString(std::wstring_view str, MyIntType& val)
{
/* custom parsing logic */
}
};
Structs & Classes
To make a C++ struct serializable, define a static constexpr auto serializedFields tuple inside the struct. This maps string keys to class properties.
The SerializedField supports both direct member pointers and getter/setter function pairs:
struct WindowConfig
{
bool maximized{ false };
int width{ 640 };
int getWidth() const { return width; }
void setWidth(int w) { width = w; }
static constexpr auto serializedFields = std::make_tuple(
// Direct member access
SerializedField{ L"Maximized", &WindowConfig::maximized },
// Getter and setter pair (types are deduced automatically)
SerializedField{ L"Width", &WindowConfig::getWidth, &WindowConfig::setWidth }
// Note: If you have an overloaded setter (e.g., void setWidth(float w);)
// you can resolve it using a cast: static_cast<void (WindowConfig::*)(int)>(&WindowConfig::setWidth)
);
};
In the ClaFi Dom, both primitive scalars and complex reflected structs are represented by the unified template class Value<T>. When you define a Value with a reflected C++ struct, the DOM engine expands the struct into a sub-tree of nested nodes. This allows you to traverse inside it (e.g., settings / L"Placement" / L"Bounds") exactly like inside a regular Section.
External Structs (Non-Intrusive)
If you need to serialize a C++ struct or class that you cannot modify, define a standalone constexpr auto serializedFields(Type) function in the same namespace.
struct ExternalPoint
{
float x, y;
};
constexpr auto serializedFields(ExternalPoint)
{
return std::make_tuple(
SerializedField{ L"X", &ExternalPoint::x },
SerializedField{ L"Y", &ExternalPoint::y }
);
}
Enums
Enums require a constexpr auto enumNames(EnumType) function in the same namespace. If the enum ends with a Count element, it is used for compile-time size checking.
enum class ColorMode
{
Dark,
Light,
Count
};
constexpr auto enumNames(ColorMode)
{
return std::array{ L"Dark", L"Light" };
}
Where a Serializer Must Be Imported
A serializer serves the file that reads or writes the value - the file holding the Dt::Value, the get<T>(), the set() or the getTo(). That file imports the serializer's module itself, or imports a module that re-exports it. A plain import in a module further up the chain does not carry it.
The rule reaches every member of a struct. The engine looks for each member's serializer in that same file, and a member whose serializer is out of sight is taken for a struct of its own. The build then stops in the engine at:
static assertion failed: 'Type must define k_serializedFields, serializedFields member, or an ADL serializedFields() function.'
The instantiation context names the file the import belongs in, and further down, the member type whose serializer it could not see.
The built-in serializers live in three modules. ClaFi.Dom re-exports the first two.
ClaFi.Core.Dom_StdSerializers-std::wstringand the string-like types,bool,int,std::size_t,float, and every enum withenumNames.ClaFi.Core.Dom_UiSerializers-Color.ClaFi.Core.Dom_TextSerializers-Textand its markers.
The module that states serializedFields for a struct re-exports the serializers its members need. Every file that can see the fields can then read and write the struct, with no import of its own:
export module MyApp.Swatch;
export import ClaFi.Core.Dom_UiSerializers;
import ClaFi.Core.System.Serialization;
import ClaFi.Core.System.UiTypes;
import ClaFi.StdLib;
export struct Swatch
{
Color fill{};
Color stroke{};
};
export constexpr auto serializedFields(const Swatch&)
{
return std::make_tuple(
SerializedField{ L"Fill", &Swatch::fill },
SerializedField{ L"Stroke", &Swatch::stroke }
);
}
Blueprint Declaration
The Dom subsystem features a C++ Domain-Specific Language (DSL) to express document blueprints. To achieve zero allocation overhead, the Dt::Section class and other DSL nodes are strictly move-only.
Schema-First Evaluation
"Value=42" from a file, it does not know if 42 is an integer, a float, or a string. The only source of truth for types and structure is the defining DSL evaluation stage executed in C++ prior to loading.
Blueprint Nodes
OnEvent,
OnChange and OnNestedChange do not occupy a position in a
section's child list. They attach to whatever node encloses them, and they connect as
soon as that node exists — before its children are applied. Nothing in the Dom
raises an event while a blueprint is being applied, so the ordering is not observable,
but it is worth knowing when reasoning about a handler's lifetime.
OnEvent comes from ClaFi::Dt, the machinery the Dom and
Grid declarative layers share, and Dom::Dt re-exports it. It reads the
event type off the handler's own parameter, which is why there is no
OnColorChange, no OnItemAdded, and no need for one.
OnChange and OnNestedChange remain as thin named wrappers so
existing blueprints keep compiling.
The following declarative constructs reside inside the ClaFi::Dom::Dt namespace:
Section(child_nodes...): Declares an anonymous section (fragment). The children are injected into the parent without wrapping them in a new named node. This is used to define document roots or to merge multiple reusable blueprint fragments.Section(key, child_nodes...): Declares a named section. Groups values together inside a logical sub-tree wrapper.Value(key, defaultValue): Declares a scalar, composite struct, or enum with a default fallback state..Value(key, defaultValue, handlers...): Declares a local value and attaches any number of event handlers to it. Each handler names the event it wants in its own parameter list, so no per-event wrapper type is needed:Value{ L"color", Rgb{}, OnEvent{ [](ChangeEvent& e) { ... } } }. A bare lambda works too —Value{ L"color", Rgb{}, [](ChangeEvent& e) { ... } }— because a Dom node's argument list holds nothing a callable could be confused with. Method pointers go throughmakeOnEvent(this, &MyForm::onColorChanged).OnEvent{ handler }inside aSection: attaches the handler to that section. ANestedChangeEvent¶meter listens to changes bubbling up from anywhere in the sub-tree; aChangeEvent¶meter listens to the section itself.Sequence(key, itemBlueprint): The array blueprint. It can declare lists of primitives, reflected composites, nested multi-dimensional sequences, or nested sections to build dynamic arrays of customized structural elements.
Blueprint Memory & Lifetime Rules
Because section blueprints are non-copyable, you must manage their lifetimes and composition using C++ standard value categories. Review the rules below to write clean, compile-safe configurations:
Do:
- Do pass inline temporaries directly (rvalues): Defining schemas inline inside constructors allows the engine to transfer ownership of the nodes with zero allocation overhead.
- Do mix references and moves dynamically: You can combine existing blueprint variables (lvalues) and temporary blueprints (rvalues) into a single layout in any order.
- Do use std::move to migrate variables: When nesting an existing blueprint variable into a new one, pass it with
std::move()to dissolve the source and transfer its memory ownership. - Do pass by reference for shared layouts: If you want a base blueprint variable to remain intact so you can reuse it across multiple schemas, pass it directly by reference instead of moving it.
Don't:
- Don't do anything that won't compile. If you get an error, inspect it carefully and refer again to the Do section to resolve it.
Low-level Manual Construction
If you need to construct or augment the structure dynamically at runtime instead of using the declarative DSL constructor, use the manual builder APIs directly on any Section node:
addSection(key): Creates and appends a nested section group.addValue(key, defaultVal): Adds a scalar or composite node (returnsValue<T>&).addSequence(key, defaultItemTemplate): Adds an array sequence container dynamically for primitives, composites, or arbitrary nesting.
A Complete Example
This example deliberately demonstrates every way of creating, merging, and transferring schemas between scopes. You won't need all of them at once in real code — a typical application is more likely to have a single const declaration with everything it needs in one place.
One rule must be obeyed without exception: the schema must be established before any file is ever read. This is worth spelling out for sequences specifically, since a sequence can look dynamic in a way that invites confusion: its item count changes freely as data comes and goes, but the sequence's own existence, and the shape each item takes, are declared once, up front, exactly like every other node in the schema. What varies at runtime is only which items are actually present — found by searching the already-declared sequence with childWhere, never by inventing new schema entries after the fact.
// AppSchemas.ixx (a small module the rest of the app shares)
export module MyProject.AppSchemas;
import ClaFi.Dom;
export namespace MyProject::Schemas
{
// Declared once. Referencing it below does not copy or move it --
// each use just points back to this same instance, so it stays intact
// for every place it's used.
export const Dt::Section DEFAULT_THEME
{
L"Theme",
Dt::Value{ L"Background", std::wstring{ L"#1e1e1e" } },
Dt::Value{ L"Accent", std::wstring{ L"#0066cc" } }
};
}
// NetworkSchema.cpp (a schema fragment built through a function, not a
// static reference -- every value here is an ordinary, fixed default
// known at compile time, same as if it were written inline)
import ClaFi.Dom;
Dt::Section makeNetworkSection()
{
return Dt::Section
{
L"Network",
Dt::Value{ L"ProxyEnabled", false },
Dt::Value{ L"TimeoutSeconds", 30 }
};
}
// AppSettings.cpp (assembling everything into one document)
import ClaFi.Dom;
import ClaFi.FileFormat.ClaFi;
import MyProject.AppSchemas;
class SettingsManager
{
public:
void onNetworkChanged(NestedChangeEvent&)
{
Networking::applySettings();
}
void initialize()
{
// A section built via a function call, with a listener attached to
// it specifically -- append() merges the handler into Network's own
// handler set, not into the whole document's.
Dt::Section networkLayout = makeNetworkSection();
networkLayout.append(Dt::Section
{
Dt::OnNestedChange{ this, &SettingsManager::onNetworkChanged }
});
Dt::Section rootLayout
{
// A key-less group: these two Values are merged in as direct
// siblings of everything else here, not nested under a section
// of their own.
Dt::Section
{
Dt::Value{ L"Language", std::wstring{ L"en" } },
Dt::Value{ L"FirstRun", true }
},
std::move(networkLayout),
// The reusable Theme blueprint -- still fully intact, and used
// again below as the root of its own separate file.
Schemas::DEFAULT_THEME,
// The set of installed plugins is data, and can grow or shrink --
// but the sequence itself, and the shape each entry has, is fixed
// schema, declared here like everything else.
Dt::Sequence{ L"Plugins", Dt::Section
{
Dt::Value{ L"Path", std::wstring{} },
Dt::Value{ L"Enabled", true }
} }
};
Document<FileFormat::ClaFi> settings{ L"app_settings.cfg", AutoSave::Yes, rootLayout };
settings.load();
// Editing anything under Network -- here, from a settings UI, or on
// the next load() -- calls onNetworkChanged.
(settings / L"Network" / L"ProxyEnabled").set(true);
// Reconcile the already-declared Plugins sequence against what's
// actually on disk right now, by identity (Path) -- not by rebuilding
// any part of the schema. First, drop entries for files that are gone:
auto& pluginSeq = (settings / L"Plugins").as<Sequence<Section>>();
for (size_t i = pluginSeq.size(); i-- > 0; )
{
const std::wstring path = (pluginSeq[i] / L"Path").get<std::wstring>();
if (!std::filesystem::exists(path))
{
pluginSeq[i].deleteSelf();
}
}
// Then add an entry for any file found that isn't tracked yet. An
// entry that's already there keeps whatever Enabled value the user
// set for it -- rescanning never resets it.
for (const auto& pluginPath : Platform::scanPluginDirectory())
{
std::wstring pathStr = pluginPath.wstring();
if (pluginSeq.childWhere<std::wstring>({ L"Path" }, pathStr))
{
continue;
}
Section& entry = pluginSeq.add();
(entry / L"Path").set(pathStr);
(entry / L"Enabled").set(true);
}
settings.save();
// DEFAULT_THEME is the same object used above -- reusing it here
// doesn't disturb the copy already embedded in "settings". Since its
// own key is "Theme", this file's root has the exact same shape it
// has when nested: accessed as (themeFile / L"Theme" / L"Background"),
// not a bare L"Background".
Document<FileFormat::ClaFi> themeFile{ L"themes/dark.clafi", AutoSave::Yes, Schemas::DEFAULT_THEME };
themeFile.load();
}
};
Reading & Writing Values
Reading, Writing & Casting Nodes
Dom provides several mechanisms to traverse and edit your values. For nested structures, the engine supports a directory-style path division operator (/) and index subscripts ([]).
In C++, the types map to your initial layout definitions. Both scalar primitives and reflected composite structs reside in the unified template class Value<T>. You can retrieve values directly or extract references to the node objects using the .as<T>() helper:
// 1. Direct retrieval (using .get<T>())
int width = (settings / L"Window" / L"Width").get<int>();
IntRect r = (settings / L"Window" / L"Placement" / L"Bounds").get<IntRect>();
// 2. Writing values
(settings / L"Window" / L"Width").set(1920);
// 3. Getting a typed reference to the node itself (using .as<Value<T>>())
auto& widthNode = (settings / L"Window" / L"Width").as<Value<int>>();
widthNode.set(1920);
// 4. Getting a typed reference to a composite value node
auto& placementNode = (settings / L"Window" / L"Placement").as<Value<WindowConfig>>();
WindowConfig win = placementNode.get();
Zero-Copy Extraction (getTo)
When working with large, complex structs or arrays, calling .get<T>() forces the engine to return a fully composed object by value, which incurs copy overhead. To eliminate this, the engine provides the .getTo(T&) method.
.getTo() recursively deserializes the sub-tree directly into your pre-allocated object. Furthermore, if you use direct member pointers in your serializedFields tuple (no setter), the DOM engine bypasses intermediate assignments and deserializes nested hierarchies directly into the target struct's physical memory footprint in-place. For Sequence nodes, it leverages .resize() on supported containers to pre-allocate capacity and deserialize elements sequentially into the underlying buffer.
// Zero-copy extraction of a complex configuration
WindowConfig win;
(settings / L"Window" / L"Placement").getTo(win);
// Zero-copy extraction of a sequence into a vector
std::vector<TabConfig> existingTabs;
(settings / L"OpenTabs").getTo(existingTabs);
Eager Type Evaluation & Zero-Overhead Resolution
The ClaFi DOM engine is strictly Schema-First. This allows the engine to bypass the severe CPU and memory overhead normally associated with loose text-parsing document trees.
.get<int>() on a leaf node is a direct O(1) memory fetch without string parsing, while calling .get<WindowConfig>() on a composite node instantly aggregates those pre-parsed leaf values into your struct with zero text-parsing overhead.
/ traversal operator does not allocate strings or copy keys. It compiles down to pure pointer operations passing std::wstring_view boundaries, ensuring your read loops and deep-path resolutions remain allocation-free.
Sequences
The Dom subsystem treats every array natively via the universal Sequence<T> component. It natively supports primitives, structured class layouts, and unlimited multidimensional nesting (e.g., arrays of arrays). All of these sequence configurations natively support allocation-free lazy evaluation and dynamic operations.
Every Sequence<T> is homogeneous by construction — every element shares the same type T, whether T is a scalar, a reflected composite, or Dom::Section itself. The two shapes below differ only in what T is and how you round-trip a value of that type.
Value Sequences
When working with standard scalar primitives or simple composites, you can easily extract or overwrite the entire sequence natively:
// 1. Read the entire sequence directly into a standard vector
std::vector<TabConfig> tabs = (settings / L"OpenTabs").get<std::vector<TabConfig>>();
// 2. Modify your collection locally
tabs.push_back(TabConfig{ .id = L"Tab3", .title = L"Settings" });
// 3. Overwrite the sequence node on the DOM in one step
(settings / L"OpenTabs").set(tabs);
Section Sequences
When you declare a structural array using a unique DSL layout (Dt::Sequence{ L"MyLayouts", Dt::Section{ ... } }), the engine creates a dynamic Sequence<Dom::Section> list node. Fresh elements added dynamically via `.add()` will instantly clone the layout blueprint's underlying runtime structures.
// 1. Retrieve the typed section sequence reference using the fluent .as() cast
auto& dynamicTabs = (settings / L"OpenTabs").as<Dom::Sequence<Dom::Section>>();
// 2. Clear all existing nested DOM nodes
dynamicTabs.clear();
// 3. Append a new nested structural node (automatically copy-initialized with the template layout)
auto& tabNode = dynamicTabs.add();
(tabNode / L"Id").set<std::wstring>(L"Tab1");
(tabNode / L"Title").set<std::wstring>(L"Home");
Search and Removal inside Sequences
The SequenceBase provides safe template search operations to locate elements matching a specific nested value, and NodeBase allows elements to destroy themselves from their parent containers:
childWhere(path, targetValue): Searches sequentially through child nodes. It safely traverses down the relative path starting from each direct child, and returns a pointer to the direct sequence child matching the target value. Passing an empty path{}compares the typed value of the direct child itself.childWhere(predicate): The general form, closer to SQL's actualWHERE— takes any callable returningbooland passes it the whole direct child (typed, e.g.const Section&for aSequence<Section>), so the predicate can check any condition, not just equality against one nested value.deleteSelf(): Instructs the node to locate its parent sequence and erase itself safely, triggering any registered structural changes automatically.
// 1. Lookup a dynamic tab section by a nested value ("Id" inside each Section sequence element)
NodeBase* foundItem = (settings / L"OpenTabs").as<Dom::Sequence<Dom::Section>>().childWhere<std::wstring>({ L"Id" }, L"Tab1");
if (foundItem)
{
// 2. Read, update, or completely erase the located element from the sequence
foundItem->deleteSelf();
}
// 3. Empty paths can be used to search homogenous primitive sequences
auto& stringSeq = (settings / L"StringArray").as<Dom::Sequence<std::wstring>>();
NodeBase* foundPrimitive = stringSeq.childWhere<std::wstring>({}, L"MatchMe");
// 4. General predicate form: any boolean expression over the child, not just equality
auto& openTabs = (settings / L"OpenTabs").as<Dom::Sequence<Dom::Section>>();
Dom::Section* activeWideTab = openTabs.childWhere([](const Dom::Section& tab)
{
return (tab / L"Active").get<bool>() && (tab / L"Width").get<int>() > 400;
});
Iterating Sequences of Large or Complex Values
For sequences of small scalars this distinction rarely shows up, but for a Sequence<Dom::Section> or a sequence of large composites, it matters which accessor you reach for. There are two genuinely different operations that look similar at the call site:
.get<T>(), and range-forover the range returned by.get(), build an independent, detached snapshot. Every element is freshly materialized on each call — for aSectionelement, that means its entire sub-tree is recursively cloned. This is the right tool when you need a copy that will outlive the current DOM state, or that you intend to hand off elsewhere, but it is expensive if you are only reading.operator[], and range-fordirectly over theSequence<T>itself, return a live reference to the actual child node — nothing is cloned. This is the right tool for read-only iteration, and for in-place edits you mean to keep on the tree.
// Expensive: clones every element's whole sub-tree, on every single pass
for (const auto& tab : (settings / L"OpenTabs").get<std::vector<TabConfig>>())
{
// tab is an independent, already-detached copy
}
// Zero-copy: iterates the live child nodes directly, nothing is cloned
auto& openTabs = (settings / L"OpenTabs").as<Dom::Sequence<Dom::Section>>();
for (const Dom::Section& tab : openTabs)
{
// tab is a reference to the actual child Section in the tree
auto title = (tab / L"Title").get<std::wstring>();
}
The same tradeoff applies to a single composite or section field, not just sequences: .get<T>() always returns a detached value, while childSection(key) and the / path operator give you a direct reference to the live node with no copy.
Events & Transactions
The Dom subsystem dispatches a ChangeEvent synchronously on the node whose value changed, and a NestedChangeEvent that bubbles from that node up through each ancestor in turn. Without batching, each field update in a set of related changes produces its own independent NestedChangeEvent bubble.
The Hierarchical Transaction System batches this bubbling. Wrapping a set of updates in an RAII Transaction targeting any sub-tree separates local state updates from structural tree updates for its duration:
- Immediate Local Updates: Local
ChangeEvents are dispatched instantly on the leaf nodes. This ensures tightly-bound inputs (like textboxes or toggles) stay synchronized without layout overhead. - Suppressed Sub-Tree Notifications: Bubbling
NestedChangeEvents are held and coalesced on the parents. This prevents heavy layout recalculations or disk-save operations while settings are in an intermediate state. No nested change events ever leak up into non-transactioned parent nodes during the middle of active updates. - Coalesced Flush on Commit: When the
Transactiongoes out of scope, a singleNestedChangeEventbubbles up from the transaction root, triggering exactly one atomic layout update and exactly one disk-saving write (ifAutoSave::Yesis enabled on your document).
void MyForm::applyCustomLayout()
{
{
// 1. Open a transaction directly on the MainForm node using the fluent API.
// Any nested bubbling is suspended within this sub-tree.
auto t = (settings / L"MainForm").startTransaction();
// 2. Perform multiple updates (each firing local ChangeEvents instantly).
(settings / L"MainForm" / L"Maximized").set(true);
(settings / L"MainForm" / L"Placement").set(WindowConfig{ .bounds = { 0, 0, 800, 600 } });
// AutoSave (if enabled) is safely suspended. No disk IO occurs yet.
}
// 3. Transaction out of scope!
// A single, unified NestedChangeEvent is bubbled up from the MainForm node.
// Our registered onUIConfigurationChanged() handler fires exactly once,
// and the configuration Document flushes to the disk exactly once!
}
The Document Wrapper
The Document<FileFormat> template handles parsing, serializing, file lock rules, and stream delegation.
Document constructor stage (via layout.apply()), the source Dt::Section blueprint does not need to outlive the Document wrapper. It can be safely destroyed immediately after construction completes.
- AutoSave: Initializing the document with
AutoSave::Yesbinds a change listener to the root section. Any modification to a child node triggers an automatic, safesave()operation. - Constructor Overloads: Supports paths, directories, and blueprint configurations directly by const reference:
Document(const std::filesystem::path& path, AutoSave autoSave = AutoSave::No, const Dt::Section& layout = {});
Supported File Formats
The core DOM engine operates independently of specific file syntaxes. File format modules convert plain text into the DOM node structure. The ClaFi framework ships with three built-in, dependency-free format modules.
| Format Module | Syntax | Best Use Case |
|---|---|---|
FileFormat::ClaFi |
ClaFi | Extremely fast parsing with highly readable syntax tailored for human-edited config files. |
FileFormat::Json |
JSON | Interoperability with web formats or third-party tools while maintaining a strict zero-dependency C++ architecture. |
FileFormat::Xml |
XML | Interoperability with XML-based tooling or legacy systems. Sequence items are written as repeated <Item> elements under the sequence's own key. |
FileFormat::Jsondoes not decode\uXXXXUnicode escapes, and requires the document root to be a JSON object ({...}).FileFormat::Xmlhas no support for CDATA sections, comments, DOCTYPE/DTD, namespaces, or numeric character references, and only recognizes double-quoted attribute values (not single-quoted).FileFormat::ClaFifollows the ClaFi 1.0 Handbook closely; malformed input degrades via schema-error events rather than a hard parse failure.
Implementing a Custom File Format
To integrate an external parser, implement a custom parser class inheriting from FileFormatBase. This example uses C++20 modules and the nlohmann/json library to parse and serialize configurations.
Implementation (ThisApp.DomFormats.JsonNlohmann)
module;
#include <../../../nlohmann/json.hpp>
export module ThisApp.DomFormats.JsonNlohmann;
import ClaFi.Dom;
import ClaFi.StdLib;
namespace ThisApp::DomFormats
{
using namespace ClaFi::Dom;
using json = nlohmann::json;
export class JsonNlohmann : public FileFormatBase
{
public:
void loadSectionFromStream(Section& section, std::wistream& stream) override;
void saveSectionToStream(const Section& section, std::wostream& stream) override;
private:
void populateNode(const json& j, NodeBase& target);
json buildJson(const NodeBase& node);
static std::wstring utf8ToWString(const std::string& str);
static std::string wStringToUtf8(const std::wstring& str);
};
// ---------------------------------------------------------
// Implementation
// ---------------------------------------------------------
std::wstring JsonNlohmann::utf8ToWString(const std::string& str)
{
std::wstring_convert<std::codecvt_utf8<wchar_t>> myconv;
return myconv.from_bytes(str);
}
std::string JsonNlohmann::wStringToUtf8(const std::wstring& str)
{
std::wstring_convert<std::codecvt_utf8<wchar_t>> myconv;
return myconv.to_bytes(str);
}
void JsonNlohmann::loadSectionFromStream(Section& section, std::wistream& stream)
{
std::wstringstream wss;
wss << stream.rdbuf();
std::string utf8Str = wStringToUtf8(wss.str());
json j = json::parse(utf8Str, nullptr, false);
if (!j.is_discarded())
{
populateNode(j, section);
}
}
void JsonNlohmann::saveSectionToStream(const Section& section, std::wostream& stream)
{
json j = buildJson(section);
stream << utf8ToWString(j.dump(4)) << L"\n";
}
void JsonNlohmann::populateNode(const json& j, NodeBase& target)
{
if (j.is_object())
{
if (target.type() == NodeType::Section || target.type() == NodeType::CompositeValue)
{
auto& sec = static_cast<Section&>(target);
for (auto& [key, value] : j.items())
{
std::wstring wkey = utf8ToWString(key);
NodeBase* childPtr = sec.child(wkey);
if (childPtr)
{
populateNode(value, *childPtr);
}
else
{
sec.emitSchemaError(sec, L"Schema mismatch: Unknown key in JSON object.");
}
}
}
else
{
target.emitSchemaError(target, L"Schema mismatch: JSON expects an object structure here.");
}
}
else if (j.is_array())
{
if (target.type() == NodeType::Sequence)
{
auto& seq = static_cast<SequenceBase&>(target);
seq.clear();
for (const auto& item : j)
{
populateNode(item, seq.addNode());
}
}
else
{
target.emitSchemaError(target, L"Schema mismatch: JSON expects an array sequence here.");
}
}
else
{
if (target.type() == NodeType::ScalarValue)
{
std::string rawVal = j.is_string() ? j.get<std::string>() : j.dump();
static_cast<ScalarValueBase&>(target).setFromRaw(utf8ToWString(rawVal));
}
else
{
target.emitSchemaError(target, L"Schema mismatch: JSON provided a primitive, but struct expected.");
}
}
}
json JsonNlohmann::buildJson(const NodeBase& node)
{
if (node.type() == NodeType::ScalarValue)
{
std::string raw = wStringToUtf8(static_cast<const ScalarValueBase&>(node).getAsRaw());
if (raw == "true")
{
return true;
}
if (raw == "false")
{
return false;
}
try
{
size_t pos;
int i = std::stoi(raw, &pos);
if (pos == raw.size())
{
return i;
}
double d = std::stod(raw, &pos);
if (pos == raw.size())
{
return d;
}
}
catch (...)
{
}
return raw;
}
else if (node.type() == NodeType::Section || node.type() == NodeType::CompositeValue)
{
json j = json::object();
auto& sec = static_cast<const Section&>(node);
for (const auto& wkey : sec.getKeys())
{
j[wStringToUtf8(std::wstring(wkey))] = buildJson(*sec.child(wkey));
}
return j;
}
else if (node.type() == NodeType::Sequence)
{
json j = json::array();
auto& seq = static_cast<const SequenceBase&>(node);
for (size_t i = 0; i < seq.size(); ++i)
{
j.push_back(buildJson(*seq.child(i)));
}
return j;
}
return json{};
}
}