Extending welder¶
welder's core is deliberately open at four seams, all joined by static polymorphism and concept-checked at compile time:
- a rod — a new binding backend (a new framework, or a whole new target language);
- a doc style — a new docstring dialect for the documentation a rod emits;
- a resolution — a new which-participates policy, injected into the traversal carriage;
- the entry point itself —
welder::weldercan be subclassed to compose bespoke routines from the same gated building blocks.
None of them require touching welder's sources, and none require your code to live in a welder namespace. This page walks through each.
Writing a rod¶
A rod is a stateless struct of static members that satisfies the
welder::rod concept. It supplies only the emission
primitives — how to register a class, a method, a module attribute in your
framework — and never re-implements traversal, annotation semantics, name
resolution or the bindability gate: those stay in the core, which drives your
hooks. If your struct satisfies the concept, every welder::welder<YourRod>
entry point works with it unchanged.
The concept shape-checks the whole contract, so a rod that omits or mis-signs
a hook fails with a clear "not a welder::rod" instead of a deep error inside
the driver. The development loop is correspondingly simple: keep two
static_asserts next to your rod and make them pass —
The contract at a glance¶
Associated statics and types — who you are and what your handles look like:
struct my_rod {
static constexpr welder::lang language{welder::lang::py}; // the target language
using module_type = my_module_handle; // passed by reference
template <class T> using class_handle_type = my_class_handle; // what make_class yields
template <class E> using enum_handle_type = my_enum_handle; // what make_enum yields
// The caster oracle: can the framework natively convert T *without* welder
// registering it? true → scalars/strings/STL your framework ships casters
// for; false → the bindability gate requires T to be welded.
template <class T>
static constexpr bool has_native_caster = /* ... */;
Class binding — a factory plus per-member hooks on the handle it returns.
Callables arrive as whole overload groups: auto Fns is a
std::array<std::meta::info, N> (N ≥ 1) of overloads sharing one target name
(resolve it from Fns[0]), computed and bindability-gated by the driver from its
resolution — a chained-def framework loops the group, a one-value-per-name
framework registers it as one overload set. Constructors arrive the same way, as
one call carrying the participating constructor reflections plus the two
driver-computed synthesized forms:
template <class T, auto Bases, std::size_t... I>
static auto make_class(module_type&, const char* name, const char* doc,
std::index_sequence<I...>); // Bases[I] spliced
template <class T, auto Ctors, bool HasDefault, bool Aggregate>
static void add_constructors(auto& cls); // the whole participating set
template <std::meta::info Mem, class Style> static void add_field(auto& cls);
template <auto Fns, class Style> static void add_method(auto& cls);
template <auto Fns, class Style> static void add_static_method(auto& cls);
template <auto Fns> static void add_operator(auto& cls);
static consteval const char* special_method_name(std::meta::info op_fn);
// your language's name for a member operator ("__add__", "__eq"),
// or nullptr if you don't expose it — this drives add_operator eligibility
Enum binding and namespace/module binding:
template <class E> static auto make_enum(module_type&, const char* name,
const char* doc);
template <std::meta::info Enum, class Style> static void add_enumerator(auto& e);
template <class E> static void finish_enum(auto& e); // e.g. export unscoped values
static auto open_module(module_type&); // -> a "session" (scratch state)
static void set_module_doc(module_type&, const char* doc);
template <auto Fns, class Style>
static auto add_function(module_type&, const char* name = nullptr);
// a free-function overload group; may return the framework's bound-function
// object — weld_function forwards whatever this yields (void when there is
// nothing to hand out)
template <std::meta::info Var, class Style>
static auto add_variable(module_type&, auto& session, const char* name = nullptr);
// same forwarding rule (the shipped rods return void here)
static module_type add_submodule(module_type&, const char* name);
static void close_module(module_type&, auto& session); // finalize the session
};
A session is backend scratch state obtained per (sub)module — an accumulator for anything your framework wants batched (the LuaCATS rod gathers text there, for instance). If you need none, return an empty struct.
What the driver does for you — and what it expects back¶
Names for classes, enums and submodules arrive at your factories pre-styled:
the driver has already applied the injected name style and any
weld_as override. The member-level hooks instead take a trailing Style
parameter and resolve their own name via
welder::name_of<Mem, language, Style, welder::ent_kind::…>() — one call that
applies the call-site override, weld_as, and the style hook in the right
precedence. Docstrings arrive the same way: doc for classes and modules is
handed to you as a const char*; for functions, fold the annotation set with
welder::function_docstring under the doc style of your
choice (or ignore it — the sol2 rod does, Lua has no runtime docstring slot).
The two factories are contract-by-documentation
make_class / make_enum return auto, and a rod's factory legitimately
does real work in its body (the sol2 rod registers constructors inside
make_class), so the concept cannot probe them without instantiating that
body against a placeholder type. They are checked the first time the driver
instantiates your rod over a real welded type — expect the errors there,
not at the static_assert.
A compiled reference¶
The repository keeps a minimal, framework-free rod that satisfies the whole
contract and is driven through every entry point as a CTest —
tests/core/rod_probe.cpp.
It is the fastest starting skeleton: copy it, replace the no-op bodies with
your framework's calls, and keep it compiling. For real-world reference
implementations, the shipped rods are ranked roughly by size: the LuaCATS rod
(text emission, no runtime), LuaBridge3, sol2, then the two Python rods.
A rod does not have to target a language runtime at all: the LuaCATS rod
emits a stub file and the trampolines rod emits a C++ header, both driven by
the same traversal. If your "framework" is a text format, module_type can be
a writer over an output stream.
Out of tree, all it takes is a header and a target that links
welder::headers; your rod struct can live in any namespace of yours —
welder::rods::… is a convention of the shipped rods, not a requirement of
the concept.
Binding a new language¶
A rod for a language welder doesn't ship needs one more thing: a language
identity for the annotations to name. The lang value space is open for
exactly this — welder reserves bit indices 0–15 of the language mask, and
welder::user_lang<Slot> mints identities from the user range (16–31),
compile-time-checked so they can never collide with a shipped language or
overflow the mask:
inline constexpr welder::lang ruby{welder::user_lang<0>};
struct [[=welder::weld(ruby)]] Gem { /* … */ };
A user language is a first-class lang: it works in weld, the per-language
marks, trust_bindable and weld_as alike, and the core resolves it under
the same resolution rule.
Two conventions keep it sound:
- One constant. Mint the identity once — in the application that owns the binding — and spell both the annotations and the rod's language from that single constant, so the two can never disagree (an annotation naming one bit while the rod reads another would simply bind nothing).
- Make the rod's language injectable. A published rod shouldn't hardcode its slot; take it as a template parameter with a default, so an application combining two third-party rods that happened to pick the same slot can re-point one at instantiation:
template <welder::lang Language = welder::user_lang<0>>
struct rod {
static constexpr welder::lang language{Language};
// … emission primitives …
};
// the app owns the slot assignment — two third-party rods, disjoint slots:
using ruby_rod = rods_ruby::rod<welder::user_lang<0>>;
using r_rod = rods_r::rod<welder::user_lang<1>>;
These semantics are locked by the compile-time test
tests/core/user_lang.cpp.
Writing a doc style¶
A doc style decides how a function's documentation pieces — the summary, the
per-parameter docs, the returns text — fold into one docstring. The three
shipped Python styles (Google,
NumPy, Sphinx) are ordinary implementations of the same one-function contract,
the welder::doc_style concept:
template <class S>
concept doc_style = requires(const welder::detail::function_doc& d) {
{ S::format(d) } -> std::same_as<std::string>;
};
format() receives the raw pieces as a welder::detail::function_doc — a
summary (const char*), the params (a span of {name, text} pairs in
declaration order) and the returns text. Any piece may be null/empty (a
function with only a returns is valid); return an empty string for a wholly
undocumented function and the rod skips emitting a docstring. The text arrives
already dedented, so a style only lays
out sections. A minimal house style:
struct terse_style {
static constexpr std::string format(const welder::detail::function_doc& d) {
std::string out{d.summary ? d.summary : ""};
for (const auto& p : d.params)
if (p.text) {
if (!out.empty()) out += '\n';
out += " ";
out += p.name ? p.name : "?";
out += " — ";
out += p.text;
}
if (d.returns) {
out += "\n -> ";
out += d.returns;
}
return out;
}
};
static_assert(welder::doc_style<terse_style>);
Keep format() constexpr, like the shipped styles: the concept doesn't require
it, but it makes the style unit-testable by static_assert and usable in any
compile-time context.
Plug it in where a rod exposes its DocStyle template parameter — today the two
Python rods (welder::rods::pybind11::rod<terse_style>,
welder::rods::nanobind::rod<terse_style>; the Lua rods have no runtime
docstring to style). A rod of your own applies a style the
same way the shipped ones do: one welder::function_docstring<Fn, Style>() call
per bound function. The analogous seam for names — the naming::name_style
concept — is covered in Naming conventions.
Custom traversal: resolutions and carriages¶
The traversal driver — the carriage — is itself a policy template,
welder::carriages::basic_carriage<Resolution>, injected as
welder::welder's third template argument. The Resolution decides which
entities participate; the carriage body owns how they are walked and
emitted; the bindability gate stays enforced in either case.
Two resolutions ship, with their carriage aliases:
| Resolution | Carriage alias | Behavior |
|---|---|---|
welder::carriages::marker_resolution |
welder::stitch_welding_carriage (default) |
Bind only where weld / policy / marks direct |
welder::carriages::greedy_resolution<> |
welder::tack_welding_carriage |
Bind an unmarked library greedily (marks still prune) |
greedy_resolution takes one knob: greedy_resolution<true> also admits every
type's protected members — the whole-pass blanket for a third-party library
that cannot carry the
policy::weld_protected
annotation. Private members stay out regardless; that boundary is not a knob.
In the cookbook
Recipe 08 — Tack welding binds a third-party
library with the stock tack carriage end to end; Recipe 09 — Custom
traversal subclasses its resolution to
prune the library's detail namespace and _underscore internals.
A bespoke resolution is a stateless struct satisfying the welder::resolution
concept — six consteval predicates (participates, is_native_base,
member_participates, class_member_participates — the per-class-member
verdict, resolved per overload and per constructor (and consulted for
nested member types too), from which the driver
computes each name's overload group, so signature-level rules prune exactly one
sibling — namespace_participates, and counts_as_registered — the bindability
gate's registration oracle: which class/enum types may appear in bound
signatures because welding under this resolution registers them) plus the
native_bases<T, L> hook. A resolution that prunes types must mirror the
pruning in counts_as_registered — nested types included (the shipped oracles
extend the member rules to class-scoped types, recursing into the enclosing
chain), or the gate will vouch for a registration the sweep never makes.
Every per-member predicate takes a trailing std::meta::info bound_into — the
entity whose binding receives the decision's subject: the welded type for
class members, the swept namespace for namespace members, the parent namespace
for a nested namespace, the type whose direct base list is being walked for
is_native_base. For class members it is held fixed through the
base-flattening recursion, so it differs from parent_of(mem) exactly when a
non-welded base's member is flattened onto a derived binding — the context a
bespoke rule needs to say "this mixin's members, but only into Derived". The
shipped resolutions ignore it. (participates and counts_as_registered have
no such parameter: the former is reached only from the manual
weld_type/weld_function/weld_variable entry points, where no reflected
context exists; the latter is a pure registration predicate.)
One hook is optional: protected_participates(mem, L, bound_into) arbitrates
a protected member's access admission per member (absent, the declaring
class's policy::weld_protected annotation decides). It is consulted for
protected members only — public members are always admitted, and private
members never are: the carriage hard-wires both before the hook, so no
resolution can expose a private member.
Since the shipped resolutions are ordinary structs, delegation is plain inheritance. For example, tack-welding a third-party library while skipping its underscore-prefixed internals:
struct skip_internal : welder::carriages::greedy_resolution<> {
static consteval bool member_participates(std::meta::info mem, welder::lang L,
welder::policy_kind pol,
std::meta::info bound_into) {
if (std::meta::has_identifier(mem) &&
std::meta::identifier_of(mem).starts_with("_"))
return false;
return welder::carriages::greedy_resolution<>::member_participates(
mem, L, pol, bound_into);
}
};
using my_carriage = welder::carriages::basic_carriage<skip_internal>;
welder::welder<rod<>, pep8, my_carriage>::weld_namespace<^^thirdparty>(m);
The carriage's members cross-reference each other (a namespace's classes bind
via bind_type, nested namespaces recurse via bind_namespace), so a
replacement carriage — as opposed to a resolution — is expected to be a
coherent whole rather than a partial override. In practice the resolution seam
is the one you want.
Composing bespoke entry points¶
Every weld_* entry point is a one-line static forward to the carriage, so
welder::welder can be subclassed to package a recurring routine — the same
gated building blocks, your orchestration:
struct my_welder : welder::welder<welder::rods::pybind11::rod<>,
welder::rods::python::pep8> {
static void weld_geometry(module_type& m) {
weld_type<Vec2>(m);
weld_type<Mesh>(m);
weld_namespace_as_submodule<^^geo::detail_math>(m, "math");
}
};
Stability¶
These seams — the welder::rod, welder::resolution, welder::doc_style and
welder::naming::name_style concepts, welder::carriages::basic_carriage, the
open language value space (welder::user_lang), and subclassing
welder::welder — are welder's supported extension surface: out-of-tree code should need nothing from
welder::detail. Pre-1.0 the hook signatures may still evolve (see the
status note in the README);
changes will be called out per release.
Next: the Languages section shows what the shipped rods do with this contract in practice.